mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-04-10 00:08:44 +00:00
The Copilot API enforces per-account prompt token limits (128K individual, 168K business) that differ from the static 200K context length advertised by the proxy. This mismatch caused Claude Code to accumulate context beyond the actual limit, triggering "prompt token count exceeds the limit of 128000" errors. Changes: - Extract max_prompt_tokens and max_output_tokens from the Copilot /models API response (capabilities.limits) and use them as the authoritative ContextLength and MaxCompletionTokens values - Add CopilotModelLimits struct and Limits() helper to parse limits from the existing Capabilities map - Fix GitLab Duo context-1m beta header not being set when routing through the Anthropic gateway (gitlab_duo_force_context_1m attr was set but only gin headers were checked) - Fix flaky parallel tests that shared global model registry state
1650 lines
56 KiB
Go
1650 lines
56 KiB
Go
package executor
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"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"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps"
|
|
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/gjson"
|
|
"github.com/tidwall/sjson"
|
|
)
|
|
|
|
const (
|
|
githubCopilotBaseURL = "https://api.githubcopilot.com"
|
|
githubCopilotChatPath = "/chat/completions"
|
|
githubCopilotResponsesPath = "/responses"
|
|
githubCopilotAuthType = "github-copilot"
|
|
githubCopilotTokenCacheTTL = 25 * time.Minute
|
|
// tokenExpiryBuffer is the time before expiry when we should refresh the token.
|
|
tokenExpiryBuffer = 5 * time.Minute
|
|
// maxScannerBufferSize is the maximum buffer size for SSE scanning (20MB).
|
|
maxScannerBufferSize = 20_971_520
|
|
|
|
// Copilot API header values.
|
|
copilotUserAgent = "GitHubCopilotChat/0.35.0"
|
|
copilotEditorVersion = "vscode/1.107.0"
|
|
copilotPluginVersion = "copilot-chat/0.35.0"
|
|
copilotIntegrationID = "vscode-chat"
|
|
copilotOpenAIIntent = "conversation-edits"
|
|
copilotGitHubAPIVer = "2025-04-01"
|
|
)
|
|
|
|
// GitHubCopilotExecutor handles requests to the GitHub Copilot API.
|
|
type GitHubCopilotExecutor struct {
|
|
cfg *config.Config
|
|
mu sync.RWMutex
|
|
cache map[string]*cachedAPIToken
|
|
}
|
|
|
|
// cachedAPIToken stores a cached Copilot API token with its expiry.
|
|
type cachedAPIToken struct {
|
|
token string
|
|
apiEndpoint string
|
|
expiresAt time.Time
|
|
}
|
|
|
|
// NewGitHubCopilotExecutor constructs a new executor instance.
|
|
func NewGitHubCopilotExecutor(cfg *config.Config) *GitHubCopilotExecutor {
|
|
return &GitHubCopilotExecutor{
|
|
cfg: cfg,
|
|
cache: make(map[string]*cachedAPIToken),
|
|
}
|
|
}
|
|
|
|
// Identifier implements ProviderExecutor.
|
|
func (e *GitHubCopilotExecutor) Identifier() string { return githubCopilotAuthType }
|
|
|
|
// PrepareRequest implements ProviderExecutor.
|
|
func (e *GitHubCopilotExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
|
|
if req == nil {
|
|
return nil
|
|
}
|
|
ctx := req.Context()
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
apiToken, _, errToken := e.ensureAPIToken(ctx, auth)
|
|
if errToken != nil {
|
|
return errToken
|
|
}
|
|
e.applyHeaders(req, apiToken, nil)
|
|
return nil
|
|
}
|
|
|
|
// HttpRequest injects GitHub Copilot credentials into the request and executes it.
|
|
func (e *GitHubCopilotExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
|
|
if req == nil {
|
|
return nil, fmt.Errorf("github-copilot executor: request is nil")
|
|
}
|
|
if ctx == nil {
|
|
ctx = req.Context()
|
|
}
|
|
httpReq := req.WithContext(ctx)
|
|
if errPrepare := e.PrepareRequest(httpReq, auth); errPrepare != nil {
|
|
return nil, errPrepare
|
|
}
|
|
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
|
return httpClient.Do(httpReq)
|
|
}
|
|
|
|
// 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, baseURL, 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
|
|
useResponses := useGitHubCopilotResponsesEndpoint(from, req.Model)
|
|
to := sdktranslator.FromString("openai")
|
|
if useResponses {
|
|
to = sdktranslator.FromString("openai-response")
|
|
}
|
|
originalPayload := bytes.Clone(req.Payload)
|
|
if len(opts.OriginalRequest) > 0 {
|
|
originalPayload = bytes.Clone(opts.OriginalRequest)
|
|
}
|
|
originalTranslated := sdktranslator.TranslateRequest(from, to, req.Model, originalPayload, false)
|
|
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
|
|
body = e.normalizeModel(req.Model, body)
|
|
body = flattenAssistantContent(body)
|
|
body = stripUnsupportedBetas(body)
|
|
|
|
// Detect vision content before input normalization removes messages
|
|
hasVision := detectVisionContent(body)
|
|
|
|
thinkingProvider := "openai"
|
|
if useResponses {
|
|
thinkingProvider = "codex"
|
|
}
|
|
body, err = thinking.ApplyThinking(body, req.Model, from.String(), thinkingProvider, e.Identifier())
|
|
if err != nil {
|
|
return resp, err
|
|
}
|
|
|
|
if useResponses {
|
|
body = normalizeGitHubCopilotResponsesInput(body)
|
|
body = normalizeGitHubCopilotResponsesTools(body)
|
|
body = applyGitHubCopilotResponsesDefaults(body)
|
|
} else {
|
|
body = normalizeGitHubCopilotChatTools(body)
|
|
}
|
|
requestedModel := payloadRequestedModel(opts, req.Model)
|
|
body = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", body, originalTranslated, requestedModel)
|
|
body, _ = sjson.SetBytes(body, "stream", false)
|
|
|
|
path := githubCopilotChatPath
|
|
if useResponses {
|
|
path = githubCopilotResponsesPath
|
|
}
|
|
url := baseURL + path
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return resp, err
|
|
}
|
|
e.applyHeaders(httpReq, apiToken, body)
|
|
|
|
// Add Copilot-Vision-Request header if the request contains vision content
|
|
if hasVision {
|
|
httpReq.Header.Set("Copilot-Vision-Request", "true")
|
|
}
|
|
|
|
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 !isHTTPSuccess(httpResp.StatusCode) {
|
|
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 useResponses && detail.TotalTokens == 0 {
|
|
detail = parseOpenAIResponsesUsage(data)
|
|
}
|
|
if detail.TotalTokens > 0 {
|
|
reporter.publish(ctx, detail)
|
|
}
|
|
|
|
var param any
|
|
var converted []byte
|
|
if useResponses && from.String() == "claude" {
|
|
converted = translateGitHubCopilotResponsesNonStreamToClaude(data)
|
|
} else {
|
|
data = normalizeGitHubCopilotReasoningField(data)
|
|
converted = sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, ¶m)
|
|
}
|
|
resp = cliproxyexecutor.Response{Payload: converted, Headers: httpResp.Header.Clone()}
|
|
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) (_ *cliproxyexecutor.StreamResult, err error) {
|
|
apiToken, baseURL, 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
|
|
useResponses := useGitHubCopilotResponsesEndpoint(from, req.Model)
|
|
to := sdktranslator.FromString("openai")
|
|
if useResponses {
|
|
to = sdktranslator.FromString("openai-response")
|
|
}
|
|
originalPayload := bytes.Clone(req.Payload)
|
|
if len(opts.OriginalRequest) > 0 {
|
|
originalPayload = bytes.Clone(opts.OriginalRequest)
|
|
}
|
|
originalTranslated := sdktranslator.TranslateRequest(from, to, req.Model, originalPayload, false)
|
|
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
|
|
body = e.normalizeModel(req.Model, body)
|
|
body = flattenAssistantContent(body)
|
|
body = stripUnsupportedBetas(body)
|
|
|
|
// Detect vision content before input normalization removes messages
|
|
hasVision := detectVisionContent(body)
|
|
|
|
thinkingProvider := "openai"
|
|
if useResponses {
|
|
thinkingProvider = "codex"
|
|
}
|
|
body, err = thinking.ApplyThinking(body, req.Model, from.String(), thinkingProvider, e.Identifier())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if useResponses {
|
|
body = normalizeGitHubCopilotResponsesInput(body)
|
|
body = normalizeGitHubCopilotResponsesTools(body)
|
|
body = applyGitHubCopilotResponsesDefaults(body)
|
|
} else {
|
|
body = normalizeGitHubCopilotChatTools(body)
|
|
}
|
|
requestedModel := payloadRequestedModel(opts, req.Model)
|
|
body = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", body, originalTranslated, requestedModel)
|
|
body, _ = sjson.SetBytes(body, "stream", true)
|
|
// Enable stream options for usage stats in stream
|
|
if !useResponses {
|
|
body, _ = sjson.SetBytes(body, "stream_options.include_usage", true)
|
|
}
|
|
|
|
path := githubCopilotChatPath
|
|
if useResponses {
|
|
path = githubCopilotResponsesPath
|
|
}
|
|
url := baseURL + path
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
e.applyHeaders(httpReq, apiToken, body)
|
|
|
|
// Add Copilot-Vision-Request header if the request contains vision content
|
|
if hasVision {
|
|
httpReq.Header.Set("Copilot-Vision-Request", "true")
|
|
}
|
|
|
|
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 !isHTTPSuccess(httpResp.StatusCode) {
|
|
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)
|
|
|
|
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, maxScannerBufferSize)
|
|
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)
|
|
} else if useResponses {
|
|
if detail, ok := parseOpenAIResponsesStreamUsage(line); ok {
|
|
reporter.publish(ctx, detail)
|
|
}
|
|
}
|
|
}
|
|
|
|
var chunks [][]byte
|
|
if useResponses && from.String() == "claude" {
|
|
chunks = translateGitHubCopilotResponsesStreamToClaude(bytes.Clone(line), ¶m)
|
|
} else {
|
|
// Strip SSE "data: " prefix before reasoning field normalization,
|
|
// since normalizeGitHubCopilotReasoningField expects pure JSON.
|
|
// Re-wrap with the prefix afterward for the translator.
|
|
normalizedLine := bytes.Clone(line)
|
|
if bytes.HasPrefix(line, dataTag) {
|
|
sseData := bytes.TrimSpace(line[len(dataTag):])
|
|
if !bytes.Equal(sseData, []byte("[DONE]")) && gjson.ValidBytes(sseData) {
|
|
normalized := normalizeGitHubCopilotReasoningField(bytes.Clone(sseData))
|
|
if !bytes.Equal(normalized, sseData) {
|
|
normalizedLine = append(append([]byte(nil), dataTag...), normalized...)
|
|
}
|
|
}
|
|
}
|
|
chunks = sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, normalizedLine, ¶m)
|
|
}
|
|
for i := range chunks {
|
|
out <- cliproxyexecutor.StreamChunk{Payload: bytes.Clone(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 &cliproxyexecutor.StreamResult{
|
|
Headers: httpResp.Header.Clone(),
|
|
Chunks: out,
|
|
}, nil
|
|
}
|
|
|
|
// CountTokens estimates token count locally using tiktoken, since the GitHub
|
|
// Copilot API does not expose a dedicated token counting endpoint.
|
|
func (e *GitHubCopilotExecutor) CountTokens(ctx context.Context, _ *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
|
|
|
from := opts.SourceFormat
|
|
to := sdktranslator.FromString("openai")
|
|
translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
|
|
|
|
enc, err := helps.TokenizerForModel(baseModel)
|
|
if err != nil {
|
|
return cliproxyexecutor.Response{}, fmt.Errorf("github copilot executor: tokenizer init failed: %w", err)
|
|
}
|
|
|
|
count, err := helps.CountOpenAIChatTokens(enc, translated)
|
|
if err != nil {
|
|
return cliproxyexecutor.Response{}, fmt.Errorf("github copilot executor: token counting failed: %w", err)
|
|
}
|
|
|
|
usageJSON := helps.BuildOpenAIUsageJSON(count)
|
|
translatedUsage := sdktranslator.TranslateTokenCount(ctx, to, from, count, usageJSON)
|
|
return cliproxyexecutor.Response{Payload: translatedUsage}, nil
|
|
}
|
|
|
|
// 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, string, error) {
|
|
if auth == nil {
|
|
return "", "", statusErr{code: http.StatusUnauthorized, msg: "missing auth"}
|
|
}
|
|
|
|
// Get the GitHub access token
|
|
accessToken := metaStringValue(auth.Metadata, "access_token")
|
|
if accessToken == "" {
|
|
return "", "", statusErr{code: http.StatusUnauthorized, msg: "missing github access token"}
|
|
}
|
|
|
|
// Check for cached API token using thread-safe access
|
|
e.mu.RLock()
|
|
if cached, ok := e.cache[accessToken]; ok && cached.expiresAt.After(time.Now().Add(tokenExpiryBuffer)) {
|
|
e.mu.RUnlock()
|
|
return cached.token, cached.apiEndpoint, nil
|
|
}
|
|
e.mu.RUnlock()
|
|
|
|
// 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)}
|
|
}
|
|
|
|
// Use endpoint from token response, fall back to default
|
|
apiEndpoint := githubCopilotBaseURL
|
|
if apiToken.Endpoints.API != "" {
|
|
apiEndpoint = strings.TrimRight(apiToken.Endpoints.API, "/")
|
|
}
|
|
|
|
// Cache the token with thread-safe access
|
|
expiresAt := time.Now().Add(githubCopilotTokenCacheTTL)
|
|
if apiToken.ExpiresAt > 0 {
|
|
expiresAt = time.Unix(apiToken.ExpiresAt, 0)
|
|
}
|
|
e.mu.Lock()
|
|
e.cache[accessToken] = &cachedAPIToken{
|
|
token: apiToken.Token,
|
|
apiEndpoint: apiEndpoint,
|
|
expiresAt: expiresAt,
|
|
}
|
|
e.mu.Unlock()
|
|
|
|
return apiToken.Token, apiEndpoint, nil
|
|
}
|
|
|
|
// applyHeaders sets the required headers for GitHub Copilot API requests.
|
|
func (e *GitHubCopilotExecutor) applyHeaders(r *http.Request, apiToken string, body []byte) {
|
|
r.Header.Set("Content-Type", "application/json")
|
|
r.Header.Set("Authorization", "Bearer "+apiToken)
|
|
r.Header.Set("Accept", "application/json")
|
|
r.Header.Set("User-Agent", copilotUserAgent)
|
|
r.Header.Set("Editor-Version", copilotEditorVersion)
|
|
r.Header.Set("Editor-Plugin-Version", copilotPluginVersion)
|
|
r.Header.Set("Openai-Intent", copilotOpenAIIntent)
|
|
r.Header.Set("Copilot-Integration-Id", copilotIntegrationID)
|
|
r.Header.Set("X-Github-Api-Version", copilotGitHubAPIVer)
|
|
r.Header.Set("X-Request-Id", uuid.NewString())
|
|
|
|
initiator := "user"
|
|
if isAgentInitiated(body) {
|
|
initiator = "agent"
|
|
}
|
|
r.Header.Set("X-Initiator", initiator)
|
|
}
|
|
|
|
// isAgentInitiated determines whether the current request is agent-initiated
|
|
// (tool callbacks, continuations) rather than user-initiated (new user prompt).
|
|
//
|
|
// GitHub Copilot uses the X-Initiator header for billing:
|
|
// - "user" → consumes premium request quota
|
|
// - "agent" → free (tool loops, continuations)
|
|
//
|
|
// The challenge: Claude Code sends tool results as role:"user" messages with
|
|
// content type "tool_result". After translation to OpenAI format, the tool_result
|
|
// part becomes a separate role:"tool" message, but if the original Claude message
|
|
// also contained text content (e.g. skill invocations, attachment descriptions),
|
|
// a role:"user" message is emitted AFTER the tool message, making the last message
|
|
// appear user-initiated when it's actually part of an agent tool loop.
|
|
//
|
|
// VSCode Copilot Chat solves this with explicit flags (iterationNumber,
|
|
// isContinuation, subAgentInvocationId). Since CPA doesn't have these flags,
|
|
// we infer agent status by checking whether the conversation contains prior
|
|
// assistant/tool messages — if it does, the current request is a continuation.
|
|
//
|
|
// References:
|
|
// - opencode#8030, opencode#15824: same root cause and fix approach
|
|
// - vscode-copilot-chat: toolCallingLoop.ts (iterationNumber === 0)
|
|
// - pi-ai: github-copilot-headers.ts (last message role check)
|
|
func isAgentInitiated(body []byte) bool {
|
|
if len(body) == 0 {
|
|
return false
|
|
}
|
|
|
|
// Chat Completions API: check messages array
|
|
if messages := gjson.GetBytes(body, "messages"); messages.Exists() && messages.IsArray() {
|
|
arr := messages.Array()
|
|
if len(arr) == 0 {
|
|
return false
|
|
}
|
|
|
|
lastRole := ""
|
|
for i := len(arr) - 1; i >= 0; i-- {
|
|
if r := arr[i].Get("role").String(); r != "" {
|
|
lastRole = r
|
|
break
|
|
}
|
|
}
|
|
|
|
// If last message is assistant or tool, clearly agent-initiated.
|
|
if lastRole == "assistant" || lastRole == "tool" {
|
|
return true
|
|
}
|
|
|
|
// If last message is "user", check whether it contains tool results
|
|
// (indicating a tool-loop continuation) or if the preceding message
|
|
// is an assistant tool_use. This is more precise than checking for
|
|
// any prior assistant message, which would false-positive on genuine
|
|
// multi-turn follow-ups.
|
|
if lastRole == "user" {
|
|
// Check if the last user message contains tool_result content
|
|
lastContent := arr[len(arr)-1].Get("content")
|
|
if lastContent.Exists() && lastContent.IsArray() {
|
|
for _, part := range lastContent.Array() {
|
|
if part.Get("type").String() == "tool_result" {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
// Check if the second-to-last message is an assistant with tool_use
|
|
if len(arr) >= 2 {
|
|
prev := arr[len(arr)-2]
|
|
if prev.Get("role").String() == "assistant" {
|
|
prevContent := prev.Get("content")
|
|
if prevContent.Exists() && prevContent.IsArray() {
|
|
for _, part := range prevContent.Array() {
|
|
if part.Get("type").String() == "tool_use" {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// Responses API: check input array
|
|
if inputs := gjson.GetBytes(body, "input"); inputs.Exists() && inputs.IsArray() {
|
|
arr := inputs.Array()
|
|
if len(arr) == 0 {
|
|
return false
|
|
}
|
|
|
|
// Check last item
|
|
last := arr[len(arr)-1]
|
|
if role := last.Get("role").String(); role == "assistant" {
|
|
return true
|
|
}
|
|
switch last.Get("type").String() {
|
|
case "function_call", "function_call_arguments", "computer_call":
|
|
return true
|
|
case "function_call_output", "function_call_response", "tool_result", "computer_call_output":
|
|
return true
|
|
}
|
|
|
|
// If last item is user-role, check for prior non-user items
|
|
for _, item := range arr {
|
|
if role := item.Get("role").String(); role == "assistant" {
|
|
return true
|
|
}
|
|
switch item.Get("type").String() {
|
|
case "function_call", "function_call_output", "function_call_response",
|
|
"function_call_arguments", "computer_call", "computer_call_output":
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// detectVisionContent checks if the request body contains vision/image content.
|
|
// Returns true if the request includes image_url or image type content blocks.
|
|
func detectVisionContent(body []byte) bool {
|
|
// Parse messages array
|
|
messagesResult := gjson.GetBytes(body, "messages")
|
|
if !messagesResult.Exists() || !messagesResult.IsArray() {
|
|
return false
|
|
}
|
|
|
|
// Check each message for vision content
|
|
for _, message := range messagesResult.Array() {
|
|
content := message.Get("content")
|
|
|
|
// If content is an array, check each content block
|
|
if content.IsArray() {
|
|
for _, block := range content.Array() {
|
|
blockType := block.Get("type").String()
|
|
// Check for image_url or image type
|
|
if blockType == "image_url" || blockType == "image" {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// normalizeModel strips the suffix (e.g. "(medium)") from the model name
|
|
// before sending to GitHub Copilot, as the upstream API does not accept
|
|
// suffixed model identifiers.
|
|
func (e *GitHubCopilotExecutor) normalizeModel(model string, body []byte) []byte {
|
|
baseModel := thinking.ParseSuffix(model).ModelName
|
|
if baseModel != model {
|
|
body, _ = sjson.SetBytes(body, "model", baseModel)
|
|
}
|
|
return body
|
|
}
|
|
|
|
// copilotUnsupportedBetas lists beta headers that are Anthropic-specific and
|
|
// must not be forwarded to GitHub Copilot. The context-1m beta enables 1M
|
|
// context on Anthropic's API, but Copilot's Claude models are limited to
|
|
// ~128K-200K. Passing it through would not enable 1M on Copilot, but stripping
|
|
// it from the translated body avoids confusing downstream translators.
|
|
var copilotUnsupportedBetas = []string{
|
|
"context-1m-2025-08-07",
|
|
}
|
|
|
|
// stripUnsupportedBetas removes Anthropic-specific beta entries from the
|
|
// translated request body. In OpenAI format the betas may appear under
|
|
// "metadata.betas" or a top-level "betas" array; in Claude format they sit at
|
|
// "betas". This function checks all known locations.
|
|
func stripUnsupportedBetas(body []byte) []byte {
|
|
betaPaths := []string{"betas", "metadata.betas"}
|
|
for _, path := range betaPaths {
|
|
arr := gjson.GetBytes(body, path)
|
|
if !arr.Exists() || !arr.IsArray() {
|
|
continue
|
|
}
|
|
var filtered []string
|
|
changed := false
|
|
for _, item := range arr.Array() {
|
|
beta := item.String()
|
|
if isCopilotUnsupportedBeta(beta) {
|
|
changed = true
|
|
continue
|
|
}
|
|
filtered = append(filtered, beta)
|
|
}
|
|
if !changed {
|
|
continue
|
|
}
|
|
if len(filtered) == 0 {
|
|
body, _ = sjson.DeleteBytes(body, path)
|
|
} else {
|
|
body, _ = sjson.SetBytes(body, path, filtered)
|
|
}
|
|
}
|
|
return body
|
|
}
|
|
|
|
func isCopilotUnsupportedBeta(beta string) bool {
|
|
return slices.Contains(copilotUnsupportedBetas, beta)
|
|
}
|
|
|
|
// normalizeGitHubCopilotReasoningField maps Copilot's non-standard
|
|
// 'reasoning_text' field to the standard OpenAI 'reasoning_content' field
|
|
// that the SDK translator expects. This handles both streaming deltas
|
|
// (choices[].delta.reasoning_text) and non-streaming messages
|
|
// (choices[].message.reasoning_text). The field is only renamed when
|
|
// 'reasoning_content' is absent or null, preserving standard responses.
|
|
// All choices are processed to support n>1 requests.
|
|
func normalizeGitHubCopilotReasoningField(data []byte) []byte {
|
|
choices := gjson.GetBytes(data, "choices")
|
|
if !choices.Exists() || !choices.IsArray() {
|
|
return data
|
|
}
|
|
for i := range choices.Array() {
|
|
// Non-streaming: choices[i].message.reasoning_text
|
|
msgRT := fmt.Sprintf("choices.%d.message.reasoning_text", i)
|
|
msgRC := fmt.Sprintf("choices.%d.message.reasoning_content", i)
|
|
if rt := gjson.GetBytes(data, msgRT); rt.Exists() && rt.String() != "" {
|
|
if rc := gjson.GetBytes(data, msgRC); !rc.Exists() || rc.Type == gjson.Null || rc.String() == "" {
|
|
data, _ = sjson.SetBytes(data, msgRC, rt.String())
|
|
}
|
|
}
|
|
// Streaming: choices[i].delta.reasoning_text
|
|
deltaRT := fmt.Sprintf("choices.%d.delta.reasoning_text", i)
|
|
deltaRC := fmt.Sprintf("choices.%d.delta.reasoning_content", i)
|
|
if rt := gjson.GetBytes(data, deltaRT); rt.Exists() && rt.String() != "" {
|
|
if rc := gjson.GetBytes(data, deltaRC); !rc.Exists() || rc.Type == gjson.Null || rc.String() == "" {
|
|
data, _ = sjson.SetBytes(data, deltaRC, rt.String())
|
|
}
|
|
}
|
|
}
|
|
return data
|
|
}
|
|
|
|
func useGitHubCopilotResponsesEndpoint(sourceFormat sdktranslator.Format, model string) bool {
|
|
if sourceFormat.String() == "openai-response" {
|
|
return true
|
|
}
|
|
baseModel := strings.ToLower(thinking.ParseSuffix(model).ModelName)
|
|
if info := registry.GetGlobalRegistry().GetModelInfo(baseModel, githubCopilotAuthType); info != nil {
|
|
return len(info.SupportedEndpoints) > 0 && !containsEndpoint(info.SupportedEndpoints, githubCopilotChatPath) && containsEndpoint(info.SupportedEndpoints, githubCopilotResponsesPath)
|
|
}
|
|
if info := lookupGitHubCopilotStaticModelInfo(baseModel); info != nil {
|
|
return len(info.SupportedEndpoints) > 0 && !containsEndpoint(info.SupportedEndpoints, githubCopilotChatPath) && containsEndpoint(info.SupportedEndpoints, githubCopilotResponsesPath)
|
|
}
|
|
return strings.Contains(baseModel, "codex")
|
|
}
|
|
|
|
func lookupGitHubCopilotStaticModelInfo(model string) *registry.ModelInfo {
|
|
for _, info := range registry.GetStaticModelDefinitionsByChannel(githubCopilotAuthType) {
|
|
if info != nil && strings.EqualFold(info.ID, model) {
|
|
return info
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func containsEndpoint(endpoints []string, endpoint string) bool {
|
|
return slices.Contains(endpoints, endpoint)
|
|
}
|
|
|
|
// flattenAssistantContent converts assistant message content from array format
|
|
// to a joined string. GitHub Copilot requires assistant content as a string;
|
|
// sending it as an array causes Claude models to re-answer all previous prompts.
|
|
func flattenAssistantContent(body []byte) []byte {
|
|
messages := gjson.GetBytes(body, "messages")
|
|
if !messages.Exists() || !messages.IsArray() {
|
|
return body
|
|
}
|
|
result := body
|
|
for i, msg := range messages.Array() {
|
|
if msg.Get("role").String() != "assistant" {
|
|
continue
|
|
}
|
|
content := msg.Get("content")
|
|
if !content.Exists() || !content.IsArray() {
|
|
continue
|
|
}
|
|
// Skip flattening if the content contains non-text blocks (tool_use, thinking, etc.)
|
|
hasNonText := false
|
|
for _, part := range content.Array() {
|
|
if t := part.Get("type").String(); t != "" && t != "text" {
|
|
hasNonText = true
|
|
break
|
|
}
|
|
}
|
|
if hasNonText {
|
|
continue
|
|
}
|
|
var textParts []string
|
|
for _, part := range content.Array() {
|
|
if part.Get("type").String() == "text" {
|
|
if t := part.Get("text").String(); t != "" {
|
|
textParts = append(textParts, t)
|
|
}
|
|
}
|
|
}
|
|
joined := strings.Join(textParts, "")
|
|
path := fmt.Sprintf("messages.%d.content", i)
|
|
result, _ = sjson.SetBytes(result, path, joined)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func normalizeGitHubCopilotChatTools(body []byte) []byte {
|
|
tools := gjson.GetBytes(body, "tools")
|
|
if tools.Exists() {
|
|
filtered := "[]"
|
|
if tools.IsArray() {
|
|
for _, tool := range tools.Array() {
|
|
if tool.Get("type").String() != "function" {
|
|
continue
|
|
}
|
|
filtered, _ = sjson.SetRaw(filtered, "-1", tool.Raw)
|
|
}
|
|
}
|
|
body, _ = sjson.SetRawBytes(body, "tools", []byte(filtered))
|
|
}
|
|
|
|
toolChoice := gjson.GetBytes(body, "tool_choice")
|
|
if !toolChoice.Exists() {
|
|
return body
|
|
}
|
|
if toolChoice.Type == gjson.String {
|
|
switch toolChoice.String() {
|
|
case "auto", "none", "required":
|
|
return body
|
|
}
|
|
}
|
|
body, _ = sjson.SetBytes(body, "tool_choice", "auto")
|
|
return body
|
|
}
|
|
|
|
func normalizeGitHubCopilotResponsesInput(body []byte) []byte {
|
|
body = stripGitHubCopilotResponsesUnsupportedFields(body)
|
|
input := gjson.GetBytes(body, "input")
|
|
if input.Exists() {
|
|
// If input is already a string or array, keep it as-is.
|
|
if input.Type == gjson.String || input.IsArray() {
|
|
return body
|
|
}
|
|
// Non-string/non-array input: stringify as fallback.
|
|
body, _ = sjson.SetBytes(body, "input", input.Raw)
|
|
return body
|
|
}
|
|
|
|
// Convert Claude messages format to OpenAI Responses API input array.
|
|
// This preserves the conversation structure (roles, tool calls, tool results)
|
|
// which is critical for multi-turn tool-use conversations.
|
|
inputArr := "[]"
|
|
|
|
// System messages → developer role
|
|
if system := gjson.GetBytes(body, "system"); system.Exists() {
|
|
var systemParts []string
|
|
if system.IsArray() {
|
|
for _, part := range system.Array() {
|
|
if txt := part.Get("text").String(); txt != "" {
|
|
systemParts = append(systemParts, txt)
|
|
}
|
|
}
|
|
} else if system.Type == gjson.String {
|
|
systemParts = append(systemParts, system.String())
|
|
}
|
|
if len(systemParts) > 0 {
|
|
msg := `{"type":"message","role":"developer","content":[]}`
|
|
for _, txt := range systemParts {
|
|
part := `{"type":"input_text","text":""}`
|
|
part, _ = sjson.Set(part, "text", txt)
|
|
msg, _ = sjson.SetRaw(msg, "content.-1", part)
|
|
}
|
|
inputArr, _ = sjson.SetRaw(inputArr, "-1", msg)
|
|
}
|
|
}
|
|
|
|
// Messages → structured input items
|
|
if messages := gjson.GetBytes(body, "messages"); messages.Exists() && messages.IsArray() {
|
|
for _, msg := range messages.Array() {
|
|
role := msg.Get("role").String()
|
|
content := msg.Get("content")
|
|
|
|
if !content.Exists() {
|
|
continue
|
|
}
|
|
|
|
// Simple string content
|
|
if content.Type == gjson.String {
|
|
textType := "input_text"
|
|
if role == "assistant" {
|
|
textType = "output_text"
|
|
}
|
|
item := `{"type":"message","role":"","content":[]}`
|
|
item, _ = sjson.Set(item, "role", role)
|
|
part := fmt.Sprintf(`{"type":"%s","text":""}`, textType)
|
|
part, _ = sjson.Set(part, "text", content.String())
|
|
item, _ = sjson.SetRaw(item, "content.-1", part)
|
|
inputArr, _ = sjson.SetRaw(inputArr, "-1", item)
|
|
continue
|
|
}
|
|
|
|
if !content.IsArray() {
|
|
continue
|
|
}
|
|
|
|
// Array content: split into message parts vs tool items
|
|
var msgParts []string
|
|
for _, c := range content.Array() {
|
|
cType := c.Get("type").String()
|
|
switch cType {
|
|
case "text":
|
|
textType := "input_text"
|
|
if role == "assistant" {
|
|
textType = "output_text"
|
|
}
|
|
part := fmt.Sprintf(`{"type":"%s","text":""}`, textType)
|
|
part, _ = sjson.Set(part, "text", c.Get("text").String())
|
|
msgParts = append(msgParts, part)
|
|
case "image":
|
|
source := c.Get("source")
|
|
if source.Exists() {
|
|
data := source.Get("data").String()
|
|
if data == "" {
|
|
data = source.Get("base64").String()
|
|
}
|
|
mediaType := source.Get("media_type").String()
|
|
if mediaType == "" {
|
|
mediaType = source.Get("mime_type").String()
|
|
}
|
|
if mediaType == "" {
|
|
mediaType = "application/octet-stream"
|
|
}
|
|
if data != "" {
|
|
part := `{"type":"input_image","image_url":""}`
|
|
part, _ = sjson.Set(part, "image_url", fmt.Sprintf("data:%s;base64,%s", mediaType, data))
|
|
msgParts = append(msgParts, part)
|
|
}
|
|
}
|
|
case "tool_use":
|
|
// Flush any accumulated message parts first
|
|
if len(msgParts) > 0 {
|
|
item := `{"type":"message","role":"","content":[]}`
|
|
item, _ = sjson.Set(item, "role", role)
|
|
for _, p := range msgParts {
|
|
item, _ = sjson.SetRaw(item, "content.-1", p)
|
|
}
|
|
inputArr, _ = sjson.SetRaw(inputArr, "-1", item)
|
|
msgParts = nil
|
|
}
|
|
fc := `{"type":"function_call","call_id":"","name":"","arguments":""}`
|
|
fc, _ = sjson.Set(fc, "call_id", c.Get("id").String())
|
|
fc, _ = sjson.Set(fc, "name", c.Get("name").String())
|
|
if inputRaw := c.Get("input"); inputRaw.Exists() {
|
|
fc, _ = sjson.Set(fc, "arguments", inputRaw.Raw)
|
|
}
|
|
inputArr, _ = sjson.SetRaw(inputArr, "-1", fc)
|
|
case "tool_result":
|
|
// Flush any accumulated message parts first
|
|
if len(msgParts) > 0 {
|
|
item := `{"type":"message","role":"","content":[]}`
|
|
item, _ = sjson.Set(item, "role", role)
|
|
for _, p := range msgParts {
|
|
item, _ = sjson.SetRaw(item, "content.-1", p)
|
|
}
|
|
inputArr, _ = sjson.SetRaw(inputArr, "-1", item)
|
|
msgParts = nil
|
|
}
|
|
fco := `{"type":"function_call_output","call_id":"","output":""}`
|
|
fco, _ = sjson.Set(fco, "call_id", c.Get("tool_use_id").String())
|
|
// Extract output text
|
|
resultContent := c.Get("content")
|
|
if resultContent.Type == gjson.String {
|
|
fco, _ = sjson.Set(fco, "output", resultContent.String())
|
|
} else if resultContent.IsArray() {
|
|
var resultParts []string
|
|
for _, rc := range resultContent.Array() {
|
|
if txt := rc.Get("text").String(); txt != "" {
|
|
resultParts = append(resultParts, txt)
|
|
}
|
|
}
|
|
fco, _ = sjson.Set(fco, "output", strings.Join(resultParts, "\n"))
|
|
} else if resultContent.Exists() {
|
|
fco, _ = sjson.Set(fco, "output", resultContent.String())
|
|
}
|
|
inputArr, _ = sjson.SetRaw(inputArr, "-1", fco)
|
|
case "thinking":
|
|
// Skip thinking blocks - not part of the API input
|
|
}
|
|
}
|
|
|
|
// Flush remaining message parts
|
|
if len(msgParts) > 0 {
|
|
item := `{"type":"message","role":"","content":[]}`
|
|
item, _ = sjson.Set(item, "role", role)
|
|
for _, p := range msgParts {
|
|
item, _ = sjson.SetRaw(item, "content.-1", p)
|
|
}
|
|
inputArr, _ = sjson.SetRaw(inputArr, "-1", item)
|
|
}
|
|
}
|
|
}
|
|
|
|
body, _ = sjson.SetRawBytes(body, "input", []byte(inputArr))
|
|
// Remove messages/system since we've converted them to input
|
|
body, _ = sjson.DeleteBytes(body, "messages")
|
|
body, _ = sjson.DeleteBytes(body, "system")
|
|
return body
|
|
}
|
|
|
|
func stripGitHubCopilotResponsesUnsupportedFields(body []byte) []byte {
|
|
// GitHub Copilot /responses rejects service_tier, so always remove it.
|
|
body, _ = sjson.DeleteBytes(body, "service_tier")
|
|
return body
|
|
}
|
|
|
|
// applyGitHubCopilotResponsesDefaults sets required fields for the Responses API
|
|
// that both vscode-copilot-chat and pi-ai always include.
|
|
//
|
|
// References:
|
|
// - vscode-copilot-chat: src/platform/endpoint/node/responsesApi.ts
|
|
// - pi-ai (badlogic/pi-mono): packages/ai/src/providers/openai-responses.ts
|
|
func applyGitHubCopilotResponsesDefaults(body []byte) []byte {
|
|
// store: false — prevents request/response storage
|
|
if !gjson.GetBytes(body, "store").Exists() {
|
|
body, _ = sjson.SetBytes(body, "store", false)
|
|
}
|
|
|
|
// include: ["reasoning.encrypted_content"] — enables reasoning content
|
|
// reuse across turns, avoiding redundant computation
|
|
if !gjson.GetBytes(body, "include").Exists() {
|
|
body, _ = sjson.SetRawBytes(body, "include", []byte(`["reasoning.encrypted_content"]`))
|
|
}
|
|
|
|
// If reasoning.effort is set but reasoning.summary is not, default to "auto"
|
|
if gjson.GetBytes(body, "reasoning.effort").Exists() && !gjson.GetBytes(body, "reasoning.summary").Exists() {
|
|
body, _ = sjson.SetBytes(body, "reasoning.summary", "auto")
|
|
}
|
|
|
|
return body
|
|
}
|
|
|
|
func normalizeGitHubCopilotResponsesTools(body []byte) []byte {
|
|
tools := gjson.GetBytes(body, "tools")
|
|
if tools.Exists() {
|
|
filtered := "[]"
|
|
if tools.IsArray() {
|
|
for _, tool := range tools.Array() {
|
|
toolType := tool.Get("type").String()
|
|
if isGitHubCopilotResponsesBuiltinTool(toolType) {
|
|
filtered, _ = sjson.SetRaw(filtered, "-1", tool.Raw)
|
|
continue
|
|
}
|
|
// Accept OpenAI format (type="function") and Claude format
|
|
// (no type field, but has top-level name + input_schema).
|
|
if toolType != "" && toolType != "function" {
|
|
continue
|
|
}
|
|
name := tool.Get("name").String()
|
|
if name == "" {
|
|
name = tool.Get("function.name").String()
|
|
}
|
|
if name == "" {
|
|
continue
|
|
}
|
|
normalized := `{"type":"function","name":""}`
|
|
normalized, _ = sjson.Set(normalized, "name", name)
|
|
if desc := tool.Get("description").String(); desc != "" {
|
|
normalized, _ = sjson.Set(normalized, "description", desc)
|
|
} else if desc = tool.Get("function.description").String(); desc != "" {
|
|
normalized, _ = sjson.Set(normalized, "description", desc)
|
|
}
|
|
if params := tool.Get("parameters"); params.Exists() {
|
|
normalized, _ = sjson.SetRaw(normalized, "parameters", params.Raw)
|
|
} else if params = tool.Get("function.parameters"); params.Exists() {
|
|
normalized, _ = sjson.SetRaw(normalized, "parameters", params.Raw)
|
|
} else if params = tool.Get("input_schema"); params.Exists() {
|
|
normalized, _ = sjson.SetRaw(normalized, "parameters", params.Raw)
|
|
}
|
|
filtered, _ = sjson.SetRaw(filtered, "-1", normalized)
|
|
}
|
|
}
|
|
body, _ = sjson.SetRawBytes(body, "tools", []byte(filtered))
|
|
}
|
|
|
|
toolChoice := gjson.GetBytes(body, "tool_choice")
|
|
if !toolChoice.Exists() {
|
|
return body
|
|
}
|
|
if toolChoice.Type == gjson.String {
|
|
switch toolChoice.String() {
|
|
case "auto", "none", "required":
|
|
return body
|
|
default:
|
|
body, _ = sjson.SetBytes(body, "tool_choice", "auto")
|
|
return body
|
|
}
|
|
}
|
|
if toolChoice.Type == gjson.JSON {
|
|
choiceType := toolChoice.Get("type").String()
|
|
if isGitHubCopilotResponsesBuiltinTool(choiceType) {
|
|
body, _ = sjson.SetRawBytes(body, "tool_choice", []byte(toolChoice.Raw))
|
|
return body
|
|
}
|
|
if choiceType == "function" {
|
|
name := toolChoice.Get("name").String()
|
|
if name == "" {
|
|
name = toolChoice.Get("function.name").String()
|
|
}
|
|
if name != "" {
|
|
normalized := `{"type":"function","name":""}`
|
|
normalized, _ = sjson.Set(normalized, "name", name)
|
|
body, _ = sjson.SetRawBytes(body, "tool_choice", []byte(normalized))
|
|
return body
|
|
}
|
|
}
|
|
}
|
|
body, _ = sjson.SetBytes(body, "tool_choice", "auto")
|
|
return body
|
|
}
|
|
|
|
func isGitHubCopilotResponsesBuiltinTool(toolType string) bool {
|
|
switch strings.TrimSpace(toolType) {
|
|
case "computer", "computer_use_preview":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func collectTextFromNode(node gjson.Result) string {
|
|
if !node.Exists() {
|
|
return ""
|
|
}
|
|
if node.Type == gjson.String {
|
|
return node.String()
|
|
}
|
|
if node.IsArray() {
|
|
var parts []string
|
|
for _, item := range node.Array() {
|
|
if item.Type == gjson.String {
|
|
if text := item.String(); text != "" {
|
|
parts = append(parts, text)
|
|
}
|
|
continue
|
|
}
|
|
if text := item.Get("text").String(); text != "" {
|
|
parts = append(parts, text)
|
|
continue
|
|
}
|
|
if nested := collectTextFromNode(item.Get("content")); nested != "" {
|
|
parts = append(parts, nested)
|
|
}
|
|
}
|
|
return strings.Join(parts, "\n")
|
|
}
|
|
if node.Type == gjson.JSON {
|
|
if text := node.Get("text").String(); text != "" {
|
|
return text
|
|
}
|
|
if nested := collectTextFromNode(node.Get("content")); nested != "" {
|
|
return nested
|
|
}
|
|
return node.Raw
|
|
}
|
|
return node.String()
|
|
}
|
|
|
|
type githubCopilotResponsesStreamToolState struct {
|
|
Index int
|
|
ID string
|
|
Name string
|
|
}
|
|
|
|
type githubCopilotResponsesStreamState struct {
|
|
MessageStarted bool
|
|
MessageStopSent bool
|
|
TextBlockStarted bool
|
|
TextBlockIndex int
|
|
NextContentIndex int
|
|
HasToolUse bool
|
|
ReasoningActive bool
|
|
ReasoningIndex int
|
|
OutputIndexToTool map[int]*githubCopilotResponsesStreamToolState
|
|
ItemIDToTool map[string]*githubCopilotResponsesStreamToolState
|
|
}
|
|
|
|
func translateGitHubCopilotResponsesNonStreamToClaude(data []byte) []byte {
|
|
root := gjson.ParseBytes(data)
|
|
out := `{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`
|
|
out, _ = sjson.Set(out, "id", root.Get("id").String())
|
|
out, _ = sjson.Set(out, "model", root.Get("model").String())
|
|
|
|
hasToolUse := false
|
|
if output := root.Get("output"); output.Exists() && output.IsArray() {
|
|
for _, item := range output.Array() {
|
|
switch item.Get("type").String() {
|
|
case "reasoning":
|
|
var thinkingText string
|
|
if summary := item.Get("summary"); summary.Exists() && summary.IsArray() {
|
|
var parts []string
|
|
for _, part := range summary.Array() {
|
|
if txt := part.Get("text").String(); txt != "" {
|
|
parts = append(parts, txt)
|
|
}
|
|
}
|
|
thinkingText = strings.Join(parts, "")
|
|
}
|
|
if thinkingText == "" {
|
|
if content := item.Get("content"); content.Exists() && content.IsArray() {
|
|
var parts []string
|
|
for _, part := range content.Array() {
|
|
if txt := part.Get("text").String(); txt != "" {
|
|
parts = append(parts, txt)
|
|
}
|
|
}
|
|
thinkingText = strings.Join(parts, "")
|
|
}
|
|
}
|
|
if thinkingText != "" {
|
|
block := `{"type":"thinking","thinking":""}`
|
|
block, _ = sjson.Set(block, "thinking", thinkingText)
|
|
out, _ = sjson.SetRaw(out, "content.-1", block)
|
|
}
|
|
case "message":
|
|
if content := item.Get("content"); content.Exists() && content.IsArray() {
|
|
for _, part := range content.Array() {
|
|
if part.Get("type").String() != "output_text" {
|
|
continue
|
|
}
|
|
text := part.Get("text").String()
|
|
if text == "" {
|
|
continue
|
|
}
|
|
block := `{"type":"text","text":""}`
|
|
block, _ = sjson.Set(block, "text", text)
|
|
out, _ = sjson.SetRaw(out, "content.-1", block)
|
|
}
|
|
}
|
|
case "function_call":
|
|
hasToolUse = true
|
|
toolUse := `{"type":"tool_use","id":"","name":"","input":{}}`
|
|
toolID := item.Get("call_id").String()
|
|
if toolID == "" {
|
|
toolID = item.Get("id").String()
|
|
}
|
|
toolUse, _ = sjson.Set(toolUse, "id", toolID)
|
|
toolUse, _ = sjson.Set(toolUse, "name", item.Get("name").String())
|
|
if args := item.Get("arguments").String(); args != "" && gjson.Valid(args) {
|
|
argObj := gjson.Parse(args)
|
|
if argObj.IsObject() {
|
|
toolUse, _ = sjson.SetRaw(toolUse, "input", argObj.Raw)
|
|
}
|
|
}
|
|
out, _ = sjson.SetRaw(out, "content.-1", toolUse)
|
|
}
|
|
}
|
|
}
|
|
|
|
inputTokens := root.Get("usage.input_tokens").Int()
|
|
outputTokens := root.Get("usage.output_tokens").Int()
|
|
cachedTokens := root.Get("usage.input_tokens_details.cached_tokens").Int()
|
|
if cachedTokens > 0 && inputTokens >= cachedTokens {
|
|
inputTokens -= cachedTokens
|
|
}
|
|
out, _ = sjson.Set(out, "usage.input_tokens", inputTokens)
|
|
out, _ = sjson.Set(out, "usage.output_tokens", outputTokens)
|
|
if cachedTokens > 0 {
|
|
out, _ = sjson.Set(out, "usage.cache_read_input_tokens", cachedTokens)
|
|
}
|
|
if hasToolUse {
|
|
out, _ = sjson.Set(out, "stop_reason", "tool_use")
|
|
} else if sr := root.Get("stop_reason").String(); sr == "max_tokens" || sr == "stop" {
|
|
out, _ = sjson.Set(out, "stop_reason", sr)
|
|
} else {
|
|
out, _ = sjson.Set(out, "stop_reason", "end_turn")
|
|
}
|
|
return []byte(out)
|
|
}
|
|
|
|
func translateGitHubCopilotResponsesStreamToClaude(line []byte, param *any) [][]byte {
|
|
if *param == nil {
|
|
*param = &githubCopilotResponsesStreamState{
|
|
TextBlockIndex: -1,
|
|
OutputIndexToTool: make(map[int]*githubCopilotResponsesStreamToolState),
|
|
ItemIDToTool: make(map[string]*githubCopilotResponsesStreamToolState),
|
|
}
|
|
}
|
|
state := (*param).(*githubCopilotResponsesStreamState)
|
|
|
|
if !bytes.HasPrefix(line, dataTag) {
|
|
return nil
|
|
}
|
|
payload := bytes.TrimSpace(line[5:])
|
|
if bytes.Equal(payload, []byte("[DONE]")) {
|
|
return nil
|
|
}
|
|
if !gjson.ValidBytes(payload) {
|
|
return nil
|
|
}
|
|
|
|
event := gjson.GetBytes(payload, "type").String()
|
|
results := make([][]byte, 0, 4)
|
|
appendResult := func(chunk string) {
|
|
results = append(results, []byte(chunk))
|
|
}
|
|
ensureMessageStart := func() {
|
|
if state.MessageStarted {
|
|
return
|
|
}
|
|
messageStart := `{"type":"message_start","message":{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}}`
|
|
messageStart, _ = sjson.Set(messageStart, "message.id", gjson.GetBytes(payload, "response.id").String())
|
|
messageStart, _ = sjson.Set(messageStart, "message.model", gjson.GetBytes(payload, "response.model").String())
|
|
appendResult("event: message_start\ndata: " + messageStart + "\n\n")
|
|
state.MessageStarted = true
|
|
}
|
|
startTextBlockIfNeeded := func() {
|
|
if state.TextBlockStarted {
|
|
return
|
|
}
|
|
if state.TextBlockIndex < 0 {
|
|
state.TextBlockIndex = state.NextContentIndex
|
|
state.NextContentIndex++
|
|
}
|
|
contentBlockStart := `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`
|
|
contentBlockStart, _ = sjson.Set(contentBlockStart, "index", state.TextBlockIndex)
|
|
appendResult("event: content_block_start\ndata: " + contentBlockStart + "\n\n")
|
|
state.TextBlockStarted = true
|
|
}
|
|
stopTextBlockIfNeeded := func() {
|
|
if !state.TextBlockStarted {
|
|
return
|
|
}
|
|
contentBlockStop := `{"type":"content_block_stop","index":0}`
|
|
contentBlockStop, _ = sjson.Set(contentBlockStop, "index", state.TextBlockIndex)
|
|
appendResult("event: content_block_stop\ndata: " + contentBlockStop + "\n\n")
|
|
state.TextBlockStarted = false
|
|
state.TextBlockIndex = -1
|
|
}
|
|
resolveTool := func(itemID string, outputIndex int) *githubCopilotResponsesStreamToolState {
|
|
if itemID != "" {
|
|
if tool, ok := state.ItemIDToTool[itemID]; ok {
|
|
return tool
|
|
}
|
|
}
|
|
if tool, ok := state.OutputIndexToTool[outputIndex]; ok {
|
|
if itemID != "" {
|
|
state.ItemIDToTool[itemID] = tool
|
|
}
|
|
return tool
|
|
}
|
|
return nil
|
|
}
|
|
|
|
switch event {
|
|
case "response.created":
|
|
ensureMessageStart()
|
|
case "response.output_text.delta":
|
|
ensureMessageStart()
|
|
startTextBlockIfNeeded()
|
|
delta := gjson.GetBytes(payload, "delta").String()
|
|
if delta != "" {
|
|
contentDelta := `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}`
|
|
contentDelta, _ = sjson.Set(contentDelta, "index", state.TextBlockIndex)
|
|
contentDelta, _ = sjson.Set(contentDelta, "delta.text", delta)
|
|
appendResult("event: content_block_delta\ndata: " + contentDelta + "\n\n")
|
|
}
|
|
case "response.reasoning_summary_part.added":
|
|
ensureMessageStart()
|
|
state.ReasoningActive = true
|
|
state.ReasoningIndex = state.NextContentIndex
|
|
state.NextContentIndex++
|
|
thinkingStart := `{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`
|
|
thinkingStart, _ = sjson.Set(thinkingStart, "index", state.ReasoningIndex)
|
|
appendResult("event: content_block_start\ndata: " + thinkingStart + "\n\n")
|
|
case "response.reasoning_summary_text.delta":
|
|
if state.ReasoningActive {
|
|
delta := gjson.GetBytes(payload, "delta").String()
|
|
if delta != "" {
|
|
thinkingDelta := `{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":""}}`
|
|
thinkingDelta, _ = sjson.Set(thinkingDelta, "index", state.ReasoningIndex)
|
|
thinkingDelta, _ = sjson.Set(thinkingDelta, "delta.thinking", delta)
|
|
appendResult("event: content_block_delta\ndata: " + thinkingDelta + "\n\n")
|
|
}
|
|
}
|
|
case "response.reasoning_summary_part.done":
|
|
if state.ReasoningActive {
|
|
thinkingStop := `{"type":"content_block_stop","index":0}`
|
|
thinkingStop, _ = sjson.Set(thinkingStop, "index", state.ReasoningIndex)
|
|
appendResult("event: content_block_stop\ndata: " + thinkingStop + "\n\n")
|
|
state.ReasoningActive = false
|
|
}
|
|
case "response.output_item.added":
|
|
if gjson.GetBytes(payload, "item.type").String() != "function_call" {
|
|
break
|
|
}
|
|
ensureMessageStart()
|
|
stopTextBlockIfNeeded()
|
|
state.HasToolUse = true
|
|
tool := &githubCopilotResponsesStreamToolState{
|
|
Index: state.NextContentIndex,
|
|
ID: gjson.GetBytes(payload, "item.call_id").String(),
|
|
Name: gjson.GetBytes(payload, "item.name").String(),
|
|
}
|
|
if tool.ID == "" {
|
|
tool.ID = gjson.GetBytes(payload, "item.id").String()
|
|
}
|
|
state.NextContentIndex++
|
|
outputIndex := int(gjson.GetBytes(payload, "output_index").Int())
|
|
state.OutputIndexToTool[outputIndex] = tool
|
|
if itemID := gjson.GetBytes(payload, "item.id").String(); itemID != "" {
|
|
state.ItemIDToTool[itemID] = tool
|
|
}
|
|
contentBlockStart := `{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`
|
|
contentBlockStart, _ = sjson.Set(contentBlockStart, "index", tool.Index)
|
|
contentBlockStart, _ = sjson.Set(contentBlockStart, "content_block.id", tool.ID)
|
|
contentBlockStart, _ = sjson.Set(contentBlockStart, "content_block.name", tool.Name)
|
|
appendResult("event: content_block_start\ndata: " + contentBlockStart + "\n\n")
|
|
case "response.output_item.delta":
|
|
item := gjson.GetBytes(payload, "item")
|
|
if item.Get("type").String() != "function_call" {
|
|
break
|
|
}
|
|
tool := resolveTool(item.Get("id").String(), int(gjson.GetBytes(payload, "output_index").Int()))
|
|
if tool == nil {
|
|
break
|
|
}
|
|
partial := gjson.GetBytes(payload, "delta").String()
|
|
if partial == "" {
|
|
partial = item.Get("arguments").String()
|
|
}
|
|
if partial == "" {
|
|
break
|
|
}
|
|
inputDelta := `{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`
|
|
inputDelta, _ = sjson.Set(inputDelta, "index", tool.Index)
|
|
inputDelta, _ = sjson.Set(inputDelta, "delta.partial_json", partial)
|
|
appendResult("event: content_block_delta\ndata: " + inputDelta + "\n\n")
|
|
case "response.function_call_arguments.delta":
|
|
// Copilot sends tool call arguments via this event type (not response.output_item.delta).
|
|
// Data format: {"delta":"...", "item_id":"...", "output_index":N, ...}
|
|
itemID := gjson.GetBytes(payload, "item_id").String()
|
|
outputIndex := int(gjson.GetBytes(payload, "output_index").Int())
|
|
tool := resolveTool(itemID, outputIndex)
|
|
if tool == nil {
|
|
break
|
|
}
|
|
partial := gjson.GetBytes(payload, "delta").String()
|
|
if partial == "" {
|
|
break
|
|
}
|
|
inputDelta := `{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`
|
|
inputDelta, _ = sjson.Set(inputDelta, "index", tool.Index)
|
|
inputDelta, _ = sjson.Set(inputDelta, "delta.partial_json", partial)
|
|
appendResult("event: content_block_delta\ndata: " + inputDelta + "\n\n")
|
|
case "response.output_item.done":
|
|
if gjson.GetBytes(payload, "item.type").String() != "function_call" {
|
|
break
|
|
}
|
|
tool := resolveTool(gjson.GetBytes(payload, "item.id").String(), int(gjson.GetBytes(payload, "output_index").Int()))
|
|
if tool == nil {
|
|
break
|
|
}
|
|
contentBlockStop := `{"type":"content_block_stop","index":0}`
|
|
contentBlockStop, _ = sjson.Set(contentBlockStop, "index", tool.Index)
|
|
appendResult("event: content_block_stop\ndata: " + contentBlockStop + "\n\n")
|
|
case "response.completed":
|
|
ensureMessageStart()
|
|
stopTextBlockIfNeeded()
|
|
if !state.MessageStopSent {
|
|
stopReason := "end_turn"
|
|
if state.HasToolUse {
|
|
stopReason = "tool_use"
|
|
} else if sr := gjson.GetBytes(payload, "response.stop_reason").String(); sr == "max_tokens" || sr == "stop" {
|
|
stopReason = sr
|
|
}
|
|
inputTokens := gjson.GetBytes(payload, "response.usage.input_tokens").Int()
|
|
outputTokens := gjson.GetBytes(payload, "response.usage.output_tokens").Int()
|
|
cachedTokens := gjson.GetBytes(payload, "response.usage.input_tokens_details.cached_tokens").Int()
|
|
if cachedTokens > 0 && inputTokens >= cachedTokens {
|
|
inputTokens -= cachedTokens
|
|
}
|
|
messageDelta := `{"type":"message_delta","delta":{"stop_reason":"","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`
|
|
messageDelta, _ = sjson.Set(messageDelta, "delta.stop_reason", stopReason)
|
|
messageDelta, _ = sjson.Set(messageDelta, "usage.input_tokens", inputTokens)
|
|
messageDelta, _ = sjson.Set(messageDelta, "usage.output_tokens", outputTokens)
|
|
if cachedTokens > 0 {
|
|
messageDelta, _ = sjson.Set(messageDelta, "usage.cache_read_input_tokens", cachedTokens)
|
|
}
|
|
appendResult("event: message_delta\ndata: " + messageDelta + "\n\n")
|
|
appendResult("event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")
|
|
state.MessageStopSent = true
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
// isHTTPSuccess checks if the status code indicates success (2xx).
|
|
func isHTTPSuccess(statusCode int) bool {
|
|
return statusCode >= 200 && statusCode < 300
|
|
}
|
|
|
|
const (
|
|
// defaultCopilotContextLength is the default context window for unknown Copilot models.
|
|
defaultCopilotContextLength = 128000
|
|
// defaultCopilotMaxCompletionTokens is the default max output tokens for unknown Copilot models.
|
|
defaultCopilotMaxCompletionTokens = 16384
|
|
)
|
|
|
|
// FetchGitHubCopilotModels dynamically fetches available models from the GitHub Copilot API.
|
|
// It exchanges the GitHub access token stored in auth.Metadata for a Copilot API token,
|
|
// then queries the /models endpoint. Falls back to the static registry on any failure.
|
|
func FetchGitHubCopilotModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *config.Config) []*registry.ModelInfo {
|
|
if auth == nil {
|
|
log.Debug("github-copilot: auth is nil, using static models")
|
|
return registry.GetGitHubCopilotModels()
|
|
}
|
|
|
|
accessToken := metaStringValue(auth.Metadata, "access_token")
|
|
if accessToken == "" {
|
|
log.Debug("github-copilot: no access_token in auth metadata, using static models")
|
|
return registry.GetGitHubCopilotModels()
|
|
}
|
|
|
|
copilotAuth := copilotauth.NewCopilotAuth(cfg)
|
|
|
|
entries, err := copilotAuth.ListModelsWithGitHubToken(ctx, accessToken)
|
|
if err != nil {
|
|
log.Warnf("github-copilot: failed to fetch dynamic models: %v, using static models", err)
|
|
return registry.GetGitHubCopilotModels()
|
|
}
|
|
|
|
if len(entries) == 0 {
|
|
log.Debug("github-copilot: API returned no models, using static models")
|
|
return registry.GetGitHubCopilotModels()
|
|
}
|
|
|
|
// Build a lookup from the static definitions so we can enrich dynamic entries
|
|
// with known context lengths, thinking support, etc.
|
|
staticMap := make(map[string]*registry.ModelInfo)
|
|
for _, m := range registry.GetGitHubCopilotModels() {
|
|
staticMap[m.ID] = m
|
|
}
|
|
|
|
now := time.Now().Unix()
|
|
models := make([]*registry.ModelInfo, 0, len(entries))
|
|
seen := make(map[string]struct{}, len(entries))
|
|
for _, entry := range entries {
|
|
if entry.ID == "" {
|
|
continue
|
|
}
|
|
// Deduplicate model IDs to avoid incorrect reference counting.
|
|
if _, dup := seen[entry.ID]; dup {
|
|
continue
|
|
}
|
|
seen[entry.ID] = struct{}{}
|
|
|
|
m := ®istry.ModelInfo{
|
|
ID: entry.ID,
|
|
Object: "model",
|
|
Created: now,
|
|
OwnedBy: "github-copilot",
|
|
Type: "github-copilot",
|
|
}
|
|
|
|
if entry.Created > 0 {
|
|
m.Created = entry.Created
|
|
}
|
|
if entry.Name != "" {
|
|
m.DisplayName = entry.Name
|
|
} else {
|
|
m.DisplayName = entry.ID
|
|
}
|
|
|
|
// Merge known metadata from the static fallback list
|
|
if static, ok := staticMap[entry.ID]; ok {
|
|
if m.DisplayName == entry.ID && static.DisplayName != "" {
|
|
m.DisplayName = static.DisplayName
|
|
}
|
|
m.Description = static.Description
|
|
m.ContextLength = static.ContextLength
|
|
m.MaxCompletionTokens = static.MaxCompletionTokens
|
|
m.SupportedEndpoints = static.SupportedEndpoints
|
|
m.Thinking = static.Thinking
|
|
} else {
|
|
// Sensible defaults for models not in the static list
|
|
m.Description = entry.ID + " via GitHub Copilot"
|
|
m.ContextLength = defaultCopilotContextLength
|
|
m.MaxCompletionTokens = defaultCopilotMaxCompletionTokens
|
|
}
|
|
|
|
// Override with real limits from the Copilot API when available.
|
|
// The API returns per-account limits (individual vs business) under
|
|
// capabilities.limits, which are more accurate than our static
|
|
// fallback values. We use max_prompt_tokens as ContextLength because
|
|
// that's the hard limit the Copilot API enforces on prompt size —
|
|
// exceeding it triggers "prompt token count exceeds the limit" errors.
|
|
if limits := entry.Limits(); limits != nil {
|
|
if limits.MaxPromptTokens > 0 {
|
|
m.ContextLength = limits.MaxPromptTokens
|
|
}
|
|
if limits.MaxOutputTokens > 0 {
|
|
m.MaxCompletionTokens = limits.MaxOutputTokens
|
|
}
|
|
}
|
|
|
|
models = append(models, m)
|
|
}
|
|
|
|
log.Infof("github-copilot: fetched %d models from API", len(models))
|
|
return models
|
|
}
|