diff --git a/CHANGELOG.md b/CHANGELOG.md index fabe6fa..09243f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ ## [Unreleased] +## [0.5.1] - 2026-07-25 + +### Bug Fixes + +- Avoid injecting the Copilot-specific `contextTier` field into native Anthropic `/v1/messages` requests, which the upstream schema rejects as an extra input + +## [0.5.0] - 2026-07-25 + +### Features + +- Auto-inject `contextTier: "long_context"` into upstream POST request bodies for models that support the 1M context window (detected from `max_context_window_tokens` in the `/models` response). Models that don't support long context (e.g., GPT-5 mini, Gemini, Claude Haiku 4.5) are left untouched. Configurable via `COPILOT2API_CONTEXT_TIER` env var: omit for auto-detect, set to empty string to disable, or set a value (e.g. `long_context`) to force for all models +- Support HTTP/HTTPS/SOCKS5 proxy for all upstream requests via standard `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` environment variables + +### Bug Fixes + +- Keep `/v1/models` compatible with Copilot's usage-based billing transition by using current model-access headers and retrying with the developer CLI catalog identity when the upstream returns the billing update notice + ## [0.4.0] - 2026-06-26 ### Features diff --git a/ampsearch/model.go b/ampsearch/model.go index d3bd8f5..db1bc3c 100644 --- a/ampsearch/model.go +++ b/ampsearch/model.go @@ -22,7 +22,10 @@ func NewModelBackend(client *upstream.Client, model string) *ModelBackend { if model == "" { model = "gpt-5-mini" } - return &ModelBackend{client: client, model: model, httpClient: &http.Client{Timeout: 30 * time.Second}} + return &ModelBackend{client: client, model: model, httpClient: &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{Proxy: http.ProxyFromEnvironment}, + }} } func (m *ModelBackend) Search(queries []string, maxResults int) ([]SearchResult, error) { diff --git a/anthropic/handler.go b/anthropic/handler.go index c40df58..e84c1f2 100644 --- a/anthropic/handler.go +++ b/anthropic/handler.go @@ -24,10 +24,9 @@ type Handler struct { } // NewHandler creates a new Anthropic handler. -// The transport is used for upstream HTTP requests (pass nil to create a new one). -func NewHandler(authClient upstream.TokenProvider, transport *http.Transport, mc *models.Cache, debug bool) *Handler { +func NewHandler(uc *upstream.Client, mc *models.Cache) *Handler { return &Handler{ - upstream: upstream.NewClient(authClient, transport, debug), + upstream: uc, models: mc, } } diff --git a/auth/client.go b/auth/client.go index 2985708..7b8199b 100644 --- a/auth/client.go +++ b/auth/client.go @@ -94,6 +94,21 @@ func (c *Client) GetToken(ctx context.Context) (string, error) { return tok.Token, nil } +// GetGitHubToken returns the stored long-lived GitHub OAuth token. It is used +// only for Copilot model-catalog compatibility fallback requests. +func (c *Client) GetGitHubToken(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + c.mu.RLock() + defer c.mu.RUnlock() + if c.creds.GitHubToken == "" { + return "", fmt.Errorf("no GitHub token available") + } + return c.creds.GitHubToken, nil +} + // GetBaseURL returns the base URL for API calls func (c *Client) GetBaseURL() string { c.mu.RLock() diff --git a/auth/token.go b/auth/token.go index 3c9f2eb..f6caa50 100644 --- a/auth/token.go +++ b/auth/token.go @@ -18,7 +18,10 @@ const ( ) // sharedHTTPClient is reused across the auth package to enable connection pooling. -var sharedHTTPClient = &http.Client{Timeout: 30 * time.Second} +var sharedHTTPClient = &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{Proxy: http.ProxyFromEnvironment}, +} type CopilotTokenResponse struct { Token string `json:"token"` @@ -112,4 +115,4 @@ func (t *CopilotToken) IsTokenUsable() bool { // Token is considered usable if it expires in more than 5 minutes return time.Until(t.ExpiresAt) > 5*time.Minute -} \ No newline at end of file +} diff --git a/gemini/handler.go b/gemini/handler.go index 90230f0..e3ded46 100644 --- a/gemini/handler.go +++ b/gemini/handler.go @@ -25,8 +25,8 @@ type Handler struct { models *models.Cache } -func NewHandler(authClient upstream.TokenProvider, transport *http.Transport, mc *models.Cache, debug bool) *Handler { - return &Handler{upstream: upstream.NewClient(authClient, transport, debug), models: mc} +func NewHandler(uc *upstream.Client, mc *models.Cache) *Handler { + return &Handler{upstream: uc, models: mc} } func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { diff --git a/internal/copilot/headers.go b/internal/copilot/headers.go index 5efbe10..2b238a0 100644 --- a/internal/copilot/headers.go +++ b/internal/copilot/headers.go @@ -11,9 +11,10 @@ import ( // Exported constants for User-Agent and version headers. const ( - CopilotUserAgent = "GitHubCopilotChat/0.39.0" - EditorVersion = "vscode/1.111.0" - EditorPluginVersion = "copilot-chat/0.39.0" + CopilotUserAgent = "GitHubCopilotChat/0.58.0" + EditorVersion = "vscode/1.120.0" + EditorPluginVersion = "copilot-chat/0.58.0" + CopilotAPIVersion = "2026-06-01" ) // AddHeaders adds required Copilot headers to the request @@ -25,7 +26,7 @@ func AddHeaders(req *http.Request, token string) { req.Header.Set("Copilot-Integration-Id", "vscode-chat") req.Header.Set("Openai-Intent", "conversation-agent") req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Github-Api-Version", "2025-04-01") + req.Header.Set("X-Github-Api-Version", CopilotAPIVersion) // Generate request ID if not present if req.Header.Get("X-Request-Id") == "" { @@ -33,6 +34,18 @@ func AddHeaders(req *http.Request, token string) { } } +// AddModelAccessHeaders adds the headers used by Copilot's model catalog +// endpoint. The catalog is a metadata request, not a conversation request. +func AddModelAccessHeaders(req *http.Request, token string, integrationID string) { + AddHeaders(req, token) + req.Header.Set("Openai-Intent", "model-access") + req.Header.Set("X-Interaction-Type", "model-access") + req.Header.Del("Content-Type") + if integrationID != "" { + req.Header.Set("Copilot-Integration-Id", integrationID) + } +} + // GenerateRequestID generates a unique request ID using crypto/rand func GenerateRequestID() string { b := make([]byte, 16) diff --git a/internal/models/models.go b/internal/models/models.go index e08b784..07a35a7 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -3,6 +3,7 @@ package models import ( "context" "encoding/json" + "errors" "fmt" "log/slog" "strings" @@ -13,12 +14,31 @@ import ( "golang.org/x/sync/singleflight" ) -// Info contains model metadata including supported endpoints. +// Info contains model metadata including supported endpoints and capabilities. type Info struct { - ID string `json:"id"` - SupportedEndpoints []string `json:"supported_endpoints"` + ID string `json:"id"` + SupportedEndpoints []string `json:"supported_endpoints"` + Capabilities *Capabilities `json:"capabilities,omitempty"` } +type Capabilities struct { + Limits *Limits `json:"limits,omitempty"` +} + +type Limits struct { + MaxContextWindowTokens int `json:"max_context_window_tokens"` +} + +const longContextThreshold = 500_000 + +func SupportsLongContext(info *Info) bool { + return info != nil && info.Capabilities != nil && + info.Capabilities.Limits != nil && + info.Capabilities.Limits.MaxContextWindowTokens >= longContextThreshold +} + +const billingMigrationNotice = "Your billing plan has changed to usage-based billing and model multipliers no longer apply. Please update your client to the latest version to see the new billing information." + // modelsListResponse is the response from the /models endpoint. type modelsListResponse struct { Data []Info `json:"data"` @@ -116,18 +136,60 @@ func (c *Cache) get(ctx context.Context) ([]byte, map[string]*Info, error) { } func (c *Cache) fetch(ctx context.Context) (cacheData, error) { - _, respData, err := c.upstream.Do(ctx, upstream.Request{ - Method: "GET", - Endpoint: "/models", - }) - if err != nil { - return cacheData{}, fmt.Errorf("models request failed: %w", err) + request := upstream.Request{ + Method: "GET", + Endpoint: "/models", + ModelAccess: true, + } + _, respData, primaryErr := c.upstream.Do(ctx, request) + if primaryErr == nil { + data, err := parseModelsResponse(respData) + if err == nil { + return data, nil + } + primaryErr = err } + if !containsBillingMigrationNotice(respData, primaryErr) { + return cacheData{}, fmt.Errorf("models request failed: %w", primaryErr) + } + + // Some accounts still reject the standard model-access identity during the + // billing migration. Match the Copilot developer CLI's fallback: retry the + // catalog with the GitHub OAuth token and its integration identity. + slog.Debug("standard models request returned the billing migration notice, trying developer CLI fallback") + _, fallbackData, fallbackErr := c.upstream.DoModelAccessWithGitHubToken(ctx, request) + if fallbackErr == nil { + data, err := parseModelsResponse(fallbackData) + if err == nil { + return data, nil + } + fallbackErr = err + } + + return cacheData{}, fmt.Errorf( + "models request failed: %w", + errors.Join(primaryErr, fmt.Errorf("developer CLI fallback failed: %w", fallbackErr)), + ) +} + +func containsBillingMigrationNotice(respData []byte, err error) bool { + if strings.Contains(string(respData), billingMigrationNotice) { + return true + } + + var upstreamErr *upstream.UpstreamError + return errors.As(err, &upstreamErr) && strings.Contains(string(upstreamErr.Body), billingMigrationNotice) +} + +func parseModelsResponse(respData []byte) (cacheData, error) { var modelsResp modelsListResponse if err := json.Unmarshal(respData, &modelsResp); err != nil { return cacheData{}, fmt.Errorf("failed to parse models response: %w", err) } + if modelsResp.Data == nil { + return cacheData{}, fmt.Errorf("models response missing data array") + } parsed := make(map[string]*Info, len(modelsResp.Data)) for i := range modelsResp.Data { diff --git a/internal/models/models_test.go b/internal/models/models_test.go index 03e6ca8..edd816b 100644 --- a/internal/models/models_test.go +++ b/internal/models/models_test.go @@ -1,6 +1,16 @@ package models -import "testing" +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/whtsky/copilot2api/internal/upstream" +) func TestPickEndpoint(t *testing.T) { tests := []struct { @@ -128,3 +138,196 @@ func TestNormalizeEndpoint(t *testing.T) { }) } } + +func TestSupportsLongContext(t *testing.T) { + tests := []struct { + name string + info *Info + want bool + }{ + { + name: "nil info", + info: nil, + want: false, + }, + { + name: "nil capabilities", + info: &Info{ID: "gpt-5-mini"}, + want: false, + }, + { + name: "nil limits", + info: &Info{ID: "gpt-5-mini", Capabilities: &Capabilities{}}, + want: false, + }, + { + name: "below threshold", + info: &Info{ID: "gpt-5-mini", Capabilities: &Capabilities{ + Limits: &Limits{MaxContextWindowTokens: 200_000}, + }}, + want: false, + }, + { + name: "at threshold", + info: &Info{ID: "gpt-5.4", Capabilities: &Capabilities{ + Limits: &Limits{MaxContextWindowTokens: 500_000}, + }}, + want: true, + }, + { + name: "above threshold (1M model)", + info: &Info{ID: "gpt-5.5", Capabilities: &Capabilities{ + Limits: &Limits{MaxContextWindowTokens: 1_050_000}, + }}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SupportsLongContext(tt.info) + if got != tt.want { + t.Errorf("SupportsLongContext() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCacheUsesModelAccessAndDeveloperCLIFallback(t *testing.T) { + const billingNotice = `"Your billing plan has changed to usage-based billing and model multipliers no longer apply. Please update your client to the latest version to see the new billing information."` + + type requestHeaders struct { + authorization string + integrationID string + intent string + interaction string + contentType string + } + var requests []requestHeaders + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, requestHeaders{ + authorization: r.Header.Get("Authorization"), + integrationID: r.Header.Get("Copilot-Integration-Id"), + intent: r.Header.Get("Openai-Intent"), + interaction: r.Header.Get("X-Interaction-Type"), + contentType: r.Header.Get("Content-Type"), + }) + w.Header().Set("Content-Type", "application/json") + if len(requests) == 1 { + _, _ = w.Write([]byte(billingNotice)) + return + } + _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"gpt-5","supported_endpoints":["/responses"]}]}`)) + })) + defer server.Close() + + provider := &modelCatalogTokenProvider{baseURL: server.URL} + client := upstream.NewClient(provider, &http.Transport{}, false) + cache := NewCache(client, time.Minute) + + raw, err := cache.GetRaw(context.Background()) + if err != nil { + t.Fatalf("GetRaw() error = %v", err) + } + if len(requests) != 2 { + t.Fatalf("expected primary request plus fallback, got %d requests", len(requests)) + } + + if got := requests[0]; got.authorization != "Bearer copilot-token" || + got.integrationID != "vscode-chat" || got.intent != "model-access" || + got.interaction != "model-access" || got.contentType != "" { + t.Errorf("primary model-access headers = %+v", got) + } + if got := requests[1]; got.authorization != "Bearer github-token" || + got.integrationID != "copilot-developer-cli" || got.intent != "model-access" || + got.interaction != "model-access" || got.contentType != "" { + t.Errorf("developer CLI fallback headers = %+v", got) + } + + var response modelsListResponse + if err := json.Unmarshal(raw, &response); err != nil { + t.Fatalf("cached response is not JSON: %v", err) + } + if len(response.Data) != 1 || response.Data[0].ID != "gpt-5" { + t.Fatalf("cached models = %+v, want gpt-5", response.Data) + } + + if _, err := cache.GetRaw(context.Background()); err != nil { + t.Fatalf("cached GetRaw() error = %v", err) + } + if len(requests) != 2 { + t.Fatalf("cached catalog triggered another upstream request: %d", len(requests)) + } +} + +func TestCacheDoesNotFallbackForUnrelatedModelsFailure(t *testing.T) { + requestCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"message":"temporary catalog failure"}`)) + })) + defer server.Close() + + provider := &modelCatalogTokenProvider{baseURL: server.URL} + client := upstream.NewClient(provider, &http.Transport{}, false) + cache := NewCache(client, time.Minute) + + if _, err := cache.GetRaw(context.Background()); err == nil { + t.Fatal("GetRaw() unexpectedly succeeded for unrelated catalog failure") + } + if requestCount != 1 { + t.Fatalf("unrelated catalog failure triggered OAuth fallback: %d requests", requestCount) + } +} + +func TestCachePreservesDeveloperCLIFallbackError(t *testing.T) { + requestCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + w.Header().Set("Content-Type", "application/json") + if requestCount == 1 { + _, _ = w.Write([]byte(billingMigrationNoticeJSON)) + return + } + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"developer CLI rejected"}`)) + })) + defer server.Close() + + provider := &modelCatalogTokenProvider{baseURL: server.URL} + client := upstream.NewClient(provider, &http.Transport{}, false) + cache := NewCache(client, time.Minute) + + _, err := cache.GetRaw(context.Background()) + if err == nil { + t.Fatal("GetRaw() unexpectedly succeeded after fallback failure") + } + var upstreamErr *upstream.UpstreamError + if !errors.As(err, &upstreamErr) { + t.Fatalf("GetRaw() error = %v, want upstream fallback error", err) + } + if upstreamErr.StatusCode != http.StatusForbidden { + t.Fatalf("fallback status = %d, want %d", upstreamErr.StatusCode, http.StatusForbidden) + } +} + +const billingMigrationNoticeJSON = `"Your billing plan has changed to usage-based billing and model multipliers no longer apply. Please update your client to the latest version to see the new billing information."` + +// modelCatalogTokenProvider models the two credentials available on auth.Client +// without exposing real tokens in tests. +type modelCatalogTokenProvider struct { + baseURL string +} + +func (p *modelCatalogTokenProvider) GetToken(context.Context) (string, error) { + return "copilot-token", nil +} + +func (p *modelCatalogTokenProvider) GetGitHubToken(context.Context) (string, error) { + return "github-token", nil +} + +func (p *modelCatalogTokenProvider) GetBaseURL() string { + return p.baseURL +} diff --git a/internal/upstream/client.go b/internal/upstream/client.go index ba2f94c..9a1c6cf 100644 --- a/internal/upstream/client.go +++ b/internal/upstream/client.go @@ -10,6 +10,7 @@ import ( "log/slog" "net" "net/http" + "strings" "time" "github.com/whtsky/copilot2api/internal/copilot" @@ -23,18 +24,32 @@ type TokenProvider interface { GetBaseURL() string } +// GitHubTokenProvider supplies the long-lived GitHub OAuth token used by +// Copilot's developer CLI model-catalog fallback. +type GitHubTokenProvider interface { + GetGitHubToken(ctx context.Context) (string, error) +} + // Client makes requests to the upstream Copilot API. type Client struct { - TokenProvider TokenProvider - HTTPClient *http.Client - Debug bool + TokenProvider TokenProvider + HTTPClient *http.Client + Debug bool + DefaultContextTier string + // LongContextChecker gates contextTier injection per model. + // When non-nil, injection only happens if the checker returns true for the + // model ID extracted from the request body. When nil, injection is + // unconditional (force mode). + LongContextChecker func(modelID string) bool } // NewTransport creates a shared http.Transport suitable for upstream requests. +// Respects HTTP_PROXY / HTTPS_PROXY / ALL_PROXY environment variables. func NewTransport() *http.Transport { return &http.Transport{ + Proxy: http.ProxyFromEnvironment, DialContext: (&net.Dialer{Timeout: 30 * time.Second}).DialContext, - TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, MaxIdleConns: 100, MaxIdleConnsPerHost: 20, IdleConnTimeout: 120 * time.Second, @@ -63,11 +78,12 @@ func NewClient(tp TokenProvider, transport *http.Transport, debug bool) *Client // Request configures a single upstream request. type Request struct { - Method string - Endpoint string - Body interface{} // []byte, io.Reader, or JSON-marshalable struct; nil for no body - QueryString string // raw query string to append (without leading '?') - Stream bool // if true, returns *http.Response instead of reading body + Method string + Endpoint string + Body interface{} // []byte, io.Reader, or JSON-marshalable struct; nil for no body + QueryString string // raw query string to append (without leading '?') + Stream bool // if true, returns *http.Response instead of reading body + ModelAccess bool // use Copilot's metadata headers for model catalog requests ExtraHeaders map[string]string // additional headers to set after copilot headers } @@ -88,7 +104,32 @@ func (c *Client) Do(ctx context.Context, r Request) (*http.Response, []byte, err if err != nil { return nil, nil, fmt.Errorf("failed to get valid token: %w", err) } + return c.do(ctx, r, token, "") +} + +// DoModelAccessWithGitHubToken retries the model catalog request using the +// long-lived GitHub OAuth token and the developer CLI integration identity. +// Some Copilot accounts reject the normal conversation identity for /models. +func (c *Client) DoModelAccessWithGitHubToken(ctx context.Context, r Request) (*http.Response, []byte, error) { + if r.Method != http.MethodGet || r.Endpoint != "/models" { + return nil, nil, fmt.Errorf("GitHub OAuth model-access fallback only supports GET /models") + } + + provider, ok := c.TokenProvider.(GitHubTokenProvider) + if !ok { + return nil, nil, fmt.Errorf("token provider does not expose a GitHub OAuth token") + } + + token, err := provider.GetGitHubToken(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub OAuth token: %w", err) + } + + r.ModelAccess = true + return c.do(ctx, r, token, "copilot-developer-cli") +} +func (c *Client) do(ctx context.Context, r Request, token string, integrationID string) (*http.Response, []byte, error) { // Resolve body to io.Reader var bodyReader io.Reader switch v := r.Body.(type) { @@ -113,6 +154,13 @@ func (c *Client) Do(ctx context.Context, r Request) (*http.Response, []byte, err upstreamURL += "?" + r.QueryString } + // Inject contextTier only for OpenAI-compatible upstream endpoints. + // Copilot's native Anthropic /v1/messages schema rejects this + // Copilot-specific field with "Extra inputs are not permitted". + if (r.Method == "" || r.Method == "POST") && supportsContextTierEndpoint(r.Endpoint) { + bodyReader = c.injectContextTier(bodyReader) + } + // Apply timeout for non-streaming requests reqCtx := ctx var cancel context.CancelFunc @@ -131,7 +179,11 @@ func (c *Client) Do(ctx context.Context, r Request) (*http.Response, []byte, err return nil, nil, fmt.Errorf("failed to create request: %w", err) } - copilot.AddHeaders(req, token) + if r.ModelAccess { + copilot.AddModelAccessHeaders(req, token, integrationID) + } else { + copilot.AddHeaders(req, token) + } if r.Stream { req.Header.Set("Accept", "text/event-stream") @@ -195,3 +247,59 @@ func truncateStr(s string, maxLen int) string { } return s[:maxLen] + "..." } + +// supportsContextTierEndpoint reports whether the endpoint accepts Copilot's +// contextTier request extension. The native Anthropic Messages endpoint does +// not: it validates the request against Anthropic's schema and rejects the +// extra top-level field. Keep this allowlist endpoint-based rather than +// model-based because a model can advertise more than one endpoint family. +func supportsContextTierEndpoint(endpoint string) bool { + endpoint = strings.TrimPrefix(endpoint, "/v1") + return endpoint == "/responses" || endpoint == "/chat/completions" +} + +// injectContextTier reads the body, injects contextTier if absent, and returns +// a new reader. Returns the original reader unchanged if injection is disabled, +// the body is nil, or the body is not valid JSON. When LongContextChecker is +// set, injection only happens if the checker approves the model in the body. +func (c *Client) injectContextTier(body io.Reader) io.Reader { + if c.DefaultContextTier == "" || body == nil { + return body + } + + data, err := io.ReadAll(body) + if err != nil || len(data) == 0 { + if len(data) > 0 { + return bytes.NewReader(data) + } + return body + } + + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return bytes.NewReader(data) + } + + if _, exists := raw["contextTier"]; exists { + return bytes.NewReader(data) + } + + if c.LongContextChecker != nil { + var modelID string + if m, ok := raw["model"]; ok { + json.Unmarshal(m, &modelID) + } + if !c.LongContextChecker(modelID) { + return bytes.NewReader(data) + } + } + + tierJSON, _ := json.Marshal(c.DefaultContextTier) + raw["contextTier"] = tierJSON + + modified, err := json.Marshal(raw) + if err != nil { + return bytes.NewReader(data) + } + return bytes.NewReader(modified) +} diff --git a/internal/upstream/client_test.go b/internal/upstream/client_test.go new file mode 100644 index 0000000..67bc241 --- /dev/null +++ b/internal/upstream/client_test.go @@ -0,0 +1,212 @@ +package upstream + +import ( + "bytes" + "encoding/json" + "io" + "testing" +) + +func TestInjectContextTier_AddsField(t *testing.T) { + c := &Client{DefaultContextTier: "long_context"} + body := []byte(`{"model":"gpt-4o","messages":[]}`) + + result := c.injectContextTier(bytes.NewReader(body)) + data, _ := io.ReadAll(result) + + var parsed map[string]json.RawMessage + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatal(err) + } + var tier string + if err := json.Unmarshal(parsed["contextTier"], &tier); err != nil { + t.Fatal("contextTier not found or not string:", err) + } + if tier != "long_context" { + t.Errorf("got %q, want %q", tier, "long_context") + } +} + +func TestInjectContextTier_PreservesExisting(t *testing.T) { + c := &Client{DefaultContextTier: "long_context"} + body := []byte(`{"model":"gpt-4o","contextTier":"default"}`) + + result := c.injectContextTier(bytes.NewReader(body)) + data, _ := io.ReadAll(result) + + var parsed map[string]json.RawMessage + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatal(err) + } + var tier string + if err := json.Unmarshal(parsed["contextTier"], &tier); err != nil { + t.Fatal(err) + } + if tier != "default" { + t.Errorf("got %q, want %q (should not override)", tier, "default") + } +} + +func TestInjectContextTier_DisabledWhenEmpty(t *testing.T) { + c := &Client{DefaultContextTier: ""} + body := []byte(`{"model":"gpt-4o"}`) + + result := c.injectContextTier(bytes.NewReader(body)) + data, _ := io.ReadAll(result) + + var parsed map[string]json.RawMessage + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatal(err) + } + if _, exists := parsed["contextTier"]; exists { + t.Error("contextTier should not be injected when DefaultContextTier is empty") + } +} + +func TestInjectContextTier_NilBody(t *testing.T) { + c := &Client{DefaultContextTier: "long_context"} + result := c.injectContextTier(nil) + if result != nil { + t.Error("expected nil for nil input") + } +} + +func TestInjectContextTier_InvalidJSON(t *testing.T) { + c := &Client{DefaultContextTier: "long_context"} + body := []byte(`not json at all`) + + result := c.injectContextTier(bytes.NewReader(body)) + data, _ := io.ReadAll(result) + + if string(data) != "not json at all" { + t.Errorf("invalid JSON should pass through unchanged, got %q", string(data)) + } +} + +func TestInjectContextTier_PreservesOtherFields(t *testing.T) { + c := &Client{DefaultContextTier: "long_context"} + body := []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}],"max_tokens":100}`) + + result := c.injectContextTier(bytes.NewReader(body)) + data, _ := io.ReadAll(result) + + var parsed map[string]json.RawMessage + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatal(err) + } + + if _, ok := parsed["model"]; !ok { + t.Error("model field lost") + } + if _, ok := parsed["messages"]; !ok { + t.Error("messages field lost") + } + if _, ok := parsed["max_tokens"]; !ok { + t.Error("max_tokens field lost") + } + if _, ok := parsed["contextTier"]; !ok { + t.Error("contextTier not added") + } +} + +func TestInjectContextTier_CheckerApproves(t *testing.T) { + c := &Client{ + DefaultContextTier: "long_context", + LongContextChecker: func(modelID string) bool { + return modelID == "gpt-5.5" + }, + } + body := []byte(`{"model":"gpt-5.5","messages":[]}`) + + result := c.injectContextTier(bytes.NewReader(body)) + data, _ := io.ReadAll(result) + + var parsed map[string]json.RawMessage + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatal(err) + } + if _, exists := parsed["contextTier"]; !exists { + t.Error("contextTier should be injected for approved model") + } +} + +func TestInjectContextTier_CheckerRejects(t *testing.T) { + c := &Client{ + DefaultContextTier: "long_context", + LongContextChecker: func(modelID string) bool { + return modelID == "gpt-5.5" + }, + } + body := []byte(`{"model":"gpt-5-mini","messages":[]}`) + + result := c.injectContextTier(bytes.NewReader(body)) + data, _ := io.ReadAll(result) + + var parsed map[string]json.RawMessage + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatal(err) + } + if _, exists := parsed["contextTier"]; exists { + t.Error("contextTier should NOT be injected for rejected model") + } +} + +func TestInjectContextTier_CheckerNoModelField(t *testing.T) { + c := &Client{ + DefaultContextTier: "long_context", + LongContextChecker: func(modelID string) bool { + return modelID != "" + }, + } + body := []byte(`{"messages":[]}`) + + result := c.injectContextTier(bytes.NewReader(body)) + data, _ := io.ReadAll(result) + + var parsed map[string]json.RawMessage + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatal(err) + } + if _, exists := parsed["contextTier"]; exists { + t.Error("contextTier should NOT be injected when no model field and checker rejects empty") + } +} + +func TestInjectContextTier_NilCheckerForceMode(t *testing.T) { + c := &Client{DefaultContextTier: "long_context"} + body := []byte(`{"model":"any-model","messages":[]}`) + + result := c.injectContextTier(bytes.NewReader(body)) + data, _ := io.ReadAll(result) + + var parsed map[string]json.RawMessage + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatal(err) + } + if _, exists := parsed["contextTier"]; !exists { + t.Error("contextTier should be injected unconditionally when checker is nil (force mode)") + } +} + +func TestSupportsContextTierEndpoint(t *testing.T) { + tests := []struct { + endpoint string + want bool + }{ + {endpoint: "/responses", want: true}, + {endpoint: "/chat/completions", want: true}, + {endpoint: "/v1/responses", want: true}, + {endpoint: "/v1/chat/completions", want: true}, + {endpoint: "/v1/messages", want: false}, + {endpoint: "/messages", want: false}, + {endpoint: "/models", want: false}, + } + + for _, tt := range tests { + t.Run(tt.endpoint, func(t *testing.T) { + if got := supportsContextTierEndpoint(tt.endpoint); got != tt.want { + t.Fatalf("supportsContextTierEndpoint(%q) = %v, want %v", tt.endpoint, got, tt.want) + } + }) + } +} diff --git a/main.go b/main.go index 12c395d..f60ca94 100644 --- a/main.go +++ b/main.go @@ -34,12 +34,9 @@ func main() { tokenDir = flag.String("token-dir", "", "Token storage directory (env: COPILOT2API_TOKEN_DIR, default: ~/.config/copilot2api)") showVersion = flag.Bool("version", false, "Show version and exit") debug = flag.Bool("debug", false, "Enable debug logging (env: COPILOT2API_DEBUG)") - ) flag.Parse() - - // Apply debug env var if !*debug { if v := os.Getenv("COPILOT2API_DEBUG"); v != "" { @@ -121,14 +118,28 @@ func main() { upstreamClient := upstream.NewClient(authClient, transport, *debug) modelsCache := models.NewCache(upstreamClient, 5*time.Minute) + // Context tier: auto-detect per model by default, env var for force/disable. + if v, ok := os.LookupEnv("COPILOT2API_CONTEXT_TIER"); ok { + upstreamClient.DefaultContextTier = v + } else { + upstreamClient.DefaultContextTier = "long_context" + upstreamClient.LongContextChecker = func(modelID string) bool { + infoMap, err := modelsCache.GetInfo(context.Background()) + if err != nil { + return false + } + return models.SupportsLongContext(infoMap[modelID]) + } + } + // Initialize proxy handler - proxyHandler := proxy.NewHandler(authClient, transport, modelsCache, *debug) + proxyHandler := proxy.NewHandler(upstreamClient, authClient, modelsCache) // Initialize Anthropic handler - anthropicHandler := anthropic.NewHandler(authClient, transport, modelsCache, *debug) + anthropicHandler := anthropic.NewHandler(upstreamClient, modelsCache) // Initialize Gemini handler - geminiHandler := gemini.NewHandler(authClient, transport, modelsCache, *debug) + geminiHandler := gemini.NewHandler(upstreamClient, modelsCache) // Set up routes mux := http.NewServeMux() diff --git a/proxy/handler.go b/proxy/handler.go index 5bdbff4..05f0709 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -24,10 +24,9 @@ type Handler struct { } // NewHandler creates a new proxy handler. -// The transport is used for upstream HTTP requests (pass nil to create a new one). -func NewHandler(authClient *auth.Client, transport *http.Transport, mc *models.Cache, debug bool) *Handler { +func NewHandler(uc *upstream.Client, authClient *auth.Client, mc *models.Cache) *Handler { return &Handler{ - upstream: upstream.NewClient(authClient, transport, debug), + upstream: uc, authClient: authClient, modelsCache: mc, } diff --git a/proxy/handler_test.go b/proxy/handler_test.go index 5c04f8d..906ced1 100644 --- a/proxy/handler_test.go +++ b/proxy/handler_test.go @@ -28,13 +28,13 @@ func TestAddCopilotHeaders(t *testing.T) { // Test required headers expectedHeaders := map[string]string{ "Authorization": "Bearer " + token, - "User-Agent": copilot.CopilotUserAgent, - "Editor-Version": copilot.EditorVersion, - "Editor-Plugin-Version": copilot.EditorPluginVersion, + "User-Agent": "GitHubCopilotChat/0.58.0", + "Editor-Version": "vscode/1.120.0", + "Editor-Plugin-Version": "copilot-chat/0.58.0", "Copilot-Integration-Id": "vscode-chat", "Openai-Intent": "conversation-agent", "Content-Type": "application/json", - "X-Github-Api-Version": "2025-04-01", + "X-Github-Api-Version": "2026-06-01", } for header, expectedValue := range expectedHeaders {