gateway, copilot: Auto's pick is asked as Copilot Chat asks it, and a refused pick is made again on its own API (infinitr0us, #256) — on a Student plan copilot/auto's pick from POST /models/session (gpt-5.4-mini, gpt-5.3-codex) was refused 400 "The requested model is not supported.", and the retry with the next pick, gpt-4.1, went to the same /responses and got "model gpt-4.1 is not supported via Responses API." (1) Copilot Chat 0.69 (now in microsoft/vscode extensions/copilot, automodeService.ts / AutoV2Fetcher) no longer asks /models/session: it POSTs {capiBase}/auto with the prompt at X-GitHub-Api-Version 2026-08-01 and gets session_token, expires_at and selected_model with its supported_endpoints; /models/session answers a fixed, stale list (asked with the owner's account it still offers gpt-5.3-codex/gpt-5.4-mini while /auto picks gpt-6-luna). magpie now asks /auto first, with the last user message as the prompt, takes the pick's endpoints for its API, keeps the session at most an hour, and falls back to /models/session and then the hand-picked model as before. (2) After a refusal the retry with Auto's next pick breaks out of forward's same-API loop when that model isn't served on it, and the candidates' loop makes the request again (at most twice) on the new model's own API; a held refusal is passed on only when no retry is left. TestCopilotAutoRepickTakesItsAPI (gpt-5.3-codex refused on /responses, gpt-4.1 refused there and served on chat) fails on the old code with infinitr0us's exact error; TestCopilotAutoAsksAuto (version, prompt, token, /models/session not asked) fails with a 502. With the owner's real Copilot account in a sandbox gateway, copilot/auto on /v1/chat/completions came back 200 from gpt-6-luna, picked by /auto and sent on /responses. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
This commit is contained in:
1 parent
98b3ed5aad
commit
719a6820df
7 files changed
+307
-2
No files matched your search
@@ -39,6 +39,8 @@ func TestCopilotAutoThroughGateway(t *testing.T) {
|
||||
case "/models":
|
||||
io.WriteString(w, `{"data":[
|
||||
{"id":"gpt-5-mini","name":"GPT-5 mini","model_picker_enabled":true,"policy":{"state":"disabled"},"supported_endpoints":["/responses"],"capabilities":{"type":"chat"}}]}`)
|
||||
case "/auto":
|
||||
w.WriteHeader(404) // Auto v2 not offered: /models/session
|
||||
case "/models/session":
|
||||
sessions++
|
||||
io.WriteString(w, `{"session_token":"auto-tok","selected_model":"gpt-5-mini","available_models":["gpt-5-mini"],"expires_at":`+
|
||||
|
||||
@@ -39,6 +39,8 @@ func TestCopilotAutoRefusedThroughGateway(t *testing.T) {
|
||||
io.WriteString(w, `{"data":[
|
||||
{"id":"claude-haiku-4.5","name":"Claude Haiku 4.5","model_picker_enabled":true,"policy":{"state":"enabled"},"supported_endpoints":["/chat/completions","/v1/messages"],"capabilities":{"type":"chat"}},
|
||||
{"id":"gpt-4.1","name":"GPT-4.1","model_picker_enabled":true,"is_chat_default":true,"is_chat_fallback":true,"policy":{"state":"enabled"},"supported_endpoints":["/chat/completions"],"capabilities":{"type":"chat"}}]}`)
|
||||
case "/auto":
|
||||
w.WriteHeader(404) // Auto v2 not offered: /models/session
|
||||
case "/models/session":
|
||||
io.WriteString(w, `{"session_token":"auto-tok","selected_model":"claude-haiku-4.5","available_models":["claude-haiku-4.5","gpt-4.1"],"expires_at":`+
|
||||
mustString(time.Now().Add(time.Hour).Unix())+`}`)
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yetone/magpie/internal/provider"
|
||||
)
|
||||
|
||||
// copilotFake signs a Copilot account in (its GitHub token gh) to a fake
|
||||
// Copilot API served by api.
|
||||
func copilotFake(t *testing.T, gh string, api http.HandlerFunc) {
|
||||
t.Helper()
|
||||
fresh(t)
|
||||
cfg := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.MkdirAll(filepath.Join(cfg, "github-copilot"), 0o755)
|
||||
os.WriteFile(filepath.Join(cfg, "github-copilot", "apps.json"), mustJSON(map[string]any{
|
||||
"github.com:Iv1.x": map[string]any{"user": "student", "oauth_token": gh},
|
||||
}), 0o600)
|
||||
up := httptest.NewServer(api)
|
||||
t.Cleanup(up.Close)
|
||||
hub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/token") {
|
||||
json.NewEncoder(w).Encode(map[string]any{"token": "sess", "expires_at": time.Now().Add(time.Hour).Unix(), "endpoints": map[string]string{"api": up.URL}})
|
||||
return
|
||||
}
|
||||
io.WriteString(w, `{"copilot_plan":"individual","access_type_sku":"free_educational_quota"}`)
|
||||
}))
|
||||
t.Cleanup(hub.Close)
|
||||
oldTok, oldUser := provider.CopilotTokenURL, provider.CopilotUserURL
|
||||
provider.CopilotTokenURL, provider.CopilotUserURL = hub.URL+"/token", hub.URL+"/user"
|
||||
t.Cleanup(func() { provider.CopilotTokenURL, provider.CopilotUserURL = oldTok, oldUser })
|
||||
}
|
||||
|
||||
const copilotStudentModels = `{"data":[
|
||||
{"id":"gpt-5.3-codex","name":"GPT-5.3-Codex","model_picker_enabled":true,"policy":{"state":"enabled"},"supported_endpoints":["/responses"],"capabilities":{"type":"chat"}},
|
||||
{"id":"gpt-4.1","name":"GPT-4.1","model_picker_enabled":true,"is_chat_default":true,"policy":{"state":"enabled"},"capabilities":{"type":"chat"}}]}`
|
||||
|
||||
const copilotNotSupported = `{"error":{"message":"The requested model is not supported.","code":"model_not_supported","param":"model","type":"invalid_request_error"}}`
|
||||
|
||||
// #256 (infinitr0us, a real Student account): Auto picked gpt-5.3-codex,
|
||||
// served on /responses alone, which Copilot refused the account; Auto then
|
||||
// picked gpt-4.1, served on chat completions alone, and the request was
|
||||
// sent again on /responses, to be refused "model gpt-4.1 is not supported
|
||||
// via Responses API". The new pick is asked for on its own API.
|
||||
func TestCopilotAutoRepickTakesItsAPI(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var asked []string // "<path> <model>"
|
||||
copilotFake(t, "gho_student256repick", func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
switch r.URL.Path {
|
||||
case "/models":
|
||||
io.WriteString(w, copilotStudentModels)
|
||||
case "/auto":
|
||||
w.WriteHeader(404) // Auto v2 not offered: /models/session
|
||||
case "/models/session":
|
||||
io.WriteString(w, `{"session_token":"auto-tok","selected_model":"gpt-5.3-codex","available_models":["gpt-5.3-codex","gpt-4.1"],"expires_at":`+
|
||||
mustString(time.Now().Add(time.Hour).Unix())+`}`)
|
||||
default:
|
||||
m := modelOf(b)
|
||||
asked = append(asked, r.URL.Path+" "+m)
|
||||
switch {
|
||||
case m == "gpt-5.3-codex":
|
||||
w.WriteHeader(400)
|
||||
io.WriteString(w, copilotNotSupported)
|
||||
case r.URL.Path != "/chat/completions":
|
||||
w.WriteHeader(400)
|
||||
io.WriteString(w, `{"error":{"message":"model `+m+` is not supported via Responses API.","code":"unsupported_api_for_model"}}`)
|
||||
default:
|
||||
io.WriteString(w, `{"id":"c1","model":"gpt-4.1","choices":[{"index":0,"message":{"role":"assistant","content":"hello from gpt-4.1"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":3}}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
s := New()
|
||||
code, body := postAs(t, s, "", `{"model":"copilot/auto","messages":[{"role":"user","content":"Reply with the single word: ok"}]}`)
|
||||
if code != 200 || !strings.Contains(body, "hello from gpt-4.1") {
|
||||
t.Fatalf("copilot/auto: %d %s", code, body)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if strings.Join(asked, "|") != "/responses gpt-5.3-codex|/chat/completions gpt-4.1" {
|
||||
t.Fatalf("Copilot was asked %q", asked)
|
||||
}
|
||||
}
|
||||
|
||||
// Copilot Chat 0.69 asks POST /auto, at API version 2026-08-01, with the
|
||||
// turn's prompt: the model it picks, on the APIs it names, signed with its
|
||||
// session; /models/session isn't asked.
|
||||
func TestCopilotAutoAsksAuto(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var asked []string
|
||||
var auto struct {
|
||||
version, prompt string
|
||||
}
|
||||
copilotFake(t, "gho_student256v2", func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
switch r.URL.Path {
|
||||
case "/models":
|
||||
io.WriteString(w, copilotStudentModels)
|
||||
case "/auto":
|
||||
var q struct {
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
json.Unmarshal(b, &q)
|
||||
auto.version, auto.prompt = r.Header.Get("X-GitHub-Api-Version"), q.Prompt
|
||||
io.WriteString(w, `{"session_token":"v2-tok","selected_model":{"id":"gpt-4.1-mini","supported_endpoints":["/chat/completions"],"capabilities":{"type":"chat"}},"expires_at":`+
|
||||
mustString(time.Now().Add(24*time.Hour).Unix())+`}`)
|
||||
case "/models/session":
|
||||
asked = append(asked, "/models/session")
|
||||
w.WriteHeader(500)
|
||||
default:
|
||||
asked = append(asked, r.URL.Path+" "+modelOf(b)+" "+r.Header.Get("Copilot-Session-Token"))
|
||||
io.WriteString(w, `{"id":"c1","model":"gpt-4.1-mini","choices":[{"index":0,"message":{"role":"assistant","content":"hello from auto"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":3}}`)
|
||||
}
|
||||
})
|
||||
s := New()
|
||||
code, body := postAs(t, s, "", `{"model":"copilot/auto","messages":[{"role":"system","content":"be brief"},{"role":"user","content":"Refactor this function"}]}`)
|
||||
if code != 200 || !strings.Contains(body, "hello from auto") {
|
||||
t.Fatalf("copilot/auto: %d %s", code, body)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if auto.version != "2026-08-01" || auto.prompt != "Refactor this function" {
|
||||
t.Errorf("/auto was asked at %q with %q", auto.version, auto.prompt)
|
||||
}
|
||||
if strings.Join(asked, "|") != "/chat/completions gpt-4.1-mini v2-tok" {
|
||||
t.Fatalf("Copilot was asked %q", asked)
|
||||
}
|
||||
}
|
||||
@@ -1402,6 +1402,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
|
||||
where := "" // the last try's provider.Where, for the usage
|
||||
again := 0 // times the last one left has been tried again
|
||||
resealed := 0 // what of the conversation another account sealed was taken out: its reasoning, then its compaction
|
||||
repicked := 0 // times Copilot's Auto was asked again for a model the account is served
|
||||
floored := false // the reply's length raised to what the provider takes
|
||||
plainFor := "" // the account and model asked again without effort updates (#617)
|
||||
// the key or subscription account the last try went to (#557)
|
||||
@@ -1422,7 +1423,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
|
||||
// for its allowance running out to be told as that one's error
|
||||
// and once the agent has the stream's headers from an earlier try's
|
||||
// keepalives, for a failure to be told as the stream's error
|
||||
hw := newHoldWriter(w, !last || again < max(lastRetries, rateRetries) || other != nil || kept.sent)
|
||||
hw := newHoldWriter(w, !last || again < max(lastRetries, rateRetries) || other != nil || kept.sent || autoPicks(c) && repicked < 2)
|
||||
hw.thinkingShown = !refusesAfterThinking(c.model)
|
||||
hw.ctx, hw.alive = r.Context(), kept
|
||||
if isGroup && g.FirstToken > 0 && !last && streams {
|
||||
@@ -1637,6 +1638,16 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
|
||||
continue
|
||||
}
|
||||
telemetry.attempt(call, try, c.p.ID, sent, hw, capture)
|
||||
if autoPicks(c) && repicked < 2 && !hw.passing && hw.code() == http.StatusBadRequest &&
|
||||
(provider.CopilotRefusal(hw.errBody()) || wrongEndpoint(hw.code(), hw.errBody())) {
|
||||
// Copilot refused the model its Auto picked, and Auto has
|
||||
// picked another, which is asked for on the APIs it is
|
||||
// served on (#256)
|
||||
repicked++
|
||||
s.trace.update(tr, func(t *Route) { t.Tries[len(t.Tries)-1] = try })
|
||||
i--
|
||||
continue
|
||||
}
|
||||
if resealed < 2 && from == provider.Responses && !hw.passing && hw.code() >= 400 && foreignReasoning.Match(hw.errBody()) {
|
||||
// the conversation moved here from another account or vendor,
|
||||
// whose sealed reasoning this one can't read: asked again
|
||||
@@ -2062,6 +2073,9 @@ func (s *Server) attempt(w http.ResponseWriter, r *http.Request, from provider.P
|
||||
// Copilot's Auto (all a Student plan may pick) is asked which model it
|
||||
// picks, as Copilot's clients do, and the request goes to that model on
|
||||
// the APIs it is served on, with the session's token
|
||||
if model == provider.CopilotAuto && p.Account != nil && p.Account.Agent == "copilot" {
|
||||
r = r.WithContext(provider.WithAutoPrompt(r.Context(), lastUserText(from, body)))
|
||||
}
|
||||
if ctx, m, err := p.ResolveAuto(r.Context(), model); err != nil {
|
||||
msg := p.Name + ": " + err.Error()
|
||||
return writeError(w, from, 502, msg), msg
|
||||
@@ -2107,6 +2121,39 @@ func (s *Server) attempt(w http.ResponseWriter, r *http.Request, from provider.P
|
||||
return s.translate(w, r, p, from, to[0], model, body, &call.Usage)
|
||||
}
|
||||
|
||||
// autoPicks is whether c is Copilot's Auto, which picks the model itself.
|
||||
func autoPicks(c candidate) bool {
|
||||
return c.model == provider.CopilotAuto && c.p.Account != nil && c.p.Account.Agent == "copilot"
|
||||
}
|
||||
|
||||
// lastUserText is the text of the request's last user turn, which
|
||||
// Copilot's Auto picks a model for.
|
||||
func lastUserText(from provider.Protocol, body []byte) string {
|
||||
req, err := parse(from, body)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for i := len(req.Messages) - 1; i >= 0; i-- {
|
||||
m := req.Messages[i]
|
||||
if m.Role != "user" {
|
||||
continue
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, p := range m.Parts {
|
||||
if p.Kind == Text {
|
||||
if b.Len() > 0 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString(p.Text)
|
||||
}
|
||||
}
|
||||
if b.Len() > 0 {
|
||||
return b.String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// markOpenRouterSharedPool keeps an upstream routing fact in the held attempt,
|
||||
// rather than exposing it as a response header.
|
||||
func markOpenRouterSharedPool(w http.ResponseWriter) {
|
||||
@@ -2133,6 +2180,13 @@ func (s *Server) forward(ctx context.Context, p provider.Provider, to provider.P
|
||||
if !p.Retry(ctx, body, res.StatusCode, b) {
|
||||
break
|
||||
}
|
||||
if m := p.AutoNext(ctx); m != "" && !slices.Contains(s.usable(p, m), to) {
|
||||
// Auto's next pick isn't served on this API (gpt-4.1 on
|
||||
// chat alone, after gpt-5.3-codex on /responses, #256): the
|
||||
// refusal is passed on, and the request made again for it
|
||||
// on its own API (the candidates' loop)
|
||||
break
|
||||
}
|
||||
if res, err = s.forwardOnce(ctx, p, to, path, body, in); err != nil || res.StatusCode != http.StatusForbidden && res.StatusCode != http.StatusBadRequest {
|
||||
return res, err
|
||||
}
|
||||
@@ -2830,7 +2884,7 @@ func (s *Server) forwardTranslated(ctx context.Context, p provider.Provider, to
|
||||
r.Effort, req = "none", &r
|
||||
continue
|
||||
}
|
||||
if req.CacheKey != "" && badRequest(res.StatusCode) && !wrongEndpoint(res.StatusCode, b) {
|
||||
if req.CacheKey != "" && badRequest(res.StatusCode) && !wrongEndpoint(res.StatusCode, b) && !provider.CopilotRefusal(b) {
|
||||
// a vendor that turns away fields it doesn't know is asked again
|
||||
// without the cache key, and not sent it again once that works —
|
||||
// at once when its error names the key; not every error does
|
||||
|
||||
@@ -16,6 +16,18 @@ package provider
|
||||
// requests otherwise leave out: without it Copilot answers 404 (#256). Its
|
||||
// answer has no selected_model: the client takes the first of
|
||||
// available_models it knows.
|
||||
//
|
||||
// Copilot Chat 0.69 (now in microsoft/vscode, extensions/copilot:
|
||||
// automodeService.ts, autoV2Fetcher.ts) asks neither: POST {api}/auto with
|
||||
// the turn's prompt, {"prompt": …}, at X-GitHub-Api-Version 2026-08-01
|
||||
// (another, or none, is 404 "invalid apiVersion"), which answers a
|
||||
// session_token, the selected_model (its id, supported_endpoints and
|
||||
// capabilities) and expires_at, a day on. Its pick is made among the
|
||||
// models the account is served, where /models/session still names the
|
||||
// same few for every plan (gpt-5.3-codex, gpt-5.4, gpt-5.4-mini,
|
||||
// claude-haiku-4.5): a Student plan was refused each of those (#256), and
|
||||
// an Individual one is picked gpt-6-luna. magpie asks /auto first, and
|
||||
// /models/session when /auto has nothing it can use.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -39,6 +51,15 @@ const CopilotAuto = "auto"
|
||||
// Auto session with; the Copilot CLI's headers name their own.
|
||||
const copilotAutoVersion = "2025-10-01"
|
||||
|
||||
// copilotAutoV2Version is the one POST /auto takes, as Copilot Chat 0.69
|
||||
// (@vscode/copilot-api 0.5) sends it.
|
||||
const copilotAutoV2Version = "2026-08-01"
|
||||
|
||||
// copilotAutoReuse is how long an Auto session is used for: /auto's lasts a
|
||||
// day, which Copilot Chat keeps for one conversation; magpie, one for the
|
||||
// account, has Copilot pick again within the hour.
|
||||
const copilotAutoReuse = time.Hour
|
||||
|
||||
// copilotAutoModel is Auto as magpie lists it.
|
||||
var copilotAutoModel = catalog.Model{ID: CopilotAuto, Name: "Auto"}
|
||||
|
||||
@@ -55,6 +76,14 @@ var (
|
||||
|
||||
type copilotAutoKey struct{}
|
||||
|
||||
type copilotAutoPromptKey struct{}
|
||||
|
||||
// WithAutoPrompt carries the prompt of the request Copilot's Auto picks a
|
||||
// model for: POST /auto picks for a prompt.
|
||||
func WithAutoPrompt(ctx context.Context, prompt string) context.Context {
|
||||
return context.WithValue(ctx, copilotAutoPromptKey{}, prompt)
|
||||
}
|
||||
|
||||
// copilotAutoResolve is the account's Auto session, asked again a couple of
|
||||
// minutes before it expires, when fresh is set or once the account was
|
||||
// refused its model.
|
||||
@@ -69,6 +98,10 @@ func copilotAutoResolve(ctx context.Context, app copilotApp, fresh bool) (copilo
|
||||
return copilotAutoSession{}, err
|
||||
}
|
||||
base := s.apiBase(app.Host)
|
||||
if a, ok := copilotAutoRoute(ctx, app, s, base); ok {
|
||||
copilotAutoSessions[app.Token] = a
|
||||
return a, nil
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/models/session", strings.NewReader(`{"auto_mode":{"model_hints":["auto"]}}`))
|
||||
if err != nil {
|
||||
return copilotAutoSession{}, err
|
||||
@@ -139,6 +172,57 @@ func copilotAutoResolve(ctx context.Context, app copilotApp, fresh bool) (copilo
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// copilotAutoRoute asks POST /auto for the model, as Copilot Chat does: not
|
||||
// ok when Copilot doesn't answer it (an older API, Auto v2 not offered the
|
||||
// account) or picks one the account was refused.
|
||||
func copilotAutoRoute(ctx context.Context, app copilotApp, s copilotSession, base string) (copilotAutoSession, bool) {
|
||||
prompt, _ := ctx.Value(copilotAutoPromptKey{}).(string)
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
prompt = "hi" // a model test, or a turn of tool results alone
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"prompt": prompt})
|
||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/auto", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return copilotAutoSession{}, false
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+s.Token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
for k, v := range s.headers() {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("X-GitHub-Api-Version", copilotAutoV2Version)
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return copilotAutoSession{}, false
|
||||
}
|
||||
defer res.Body.Close()
|
||||
var v struct {
|
||||
Token string `json:"session_token"`
|
||||
Selected struct {
|
||||
ID string `json:"id"`
|
||||
Endpoints []string `json:"supported_endpoints"`
|
||||
} `json:"selected_model"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||||
if res.StatusCode/100 != 2 || json.Unmarshal(b, &v) != nil || v.Token == "" || v.Selected.ID == "" || copilotRefuses(app.Token, v.Selected.ID) {
|
||||
return copilotAutoSession{}, false
|
||||
}
|
||||
// the pick's endpoints, which the account's list may not give
|
||||
copilotSeenMu.Lock()
|
||||
if _, ok := copilotSeen[v.Selected.ID]; !ok || len(v.Selected.Endpoints) > 0 {
|
||||
copilotSeen[v.Selected.ID] = copilotAPIs(v.Selected.Endpoints)
|
||||
}
|
||||
copilotSeenMu.Unlock()
|
||||
exp := time.Now().Add(copilotAutoReuse).Unix()
|
||||
if v.ExpiresAt > 0 && v.ExpiresAt < exp {
|
||||
exp = v.ExpiresAt
|
||||
}
|
||||
return copilotAutoSession{Token: v.Token, Model: v.Selected.ID, ExpiresAt: exp}, true
|
||||
}
|
||||
|
||||
// copilotAutoFallback stands in for an Auto session Copilot has none of for
|
||||
// the account (404): the model it may pick by hand likeliest served (see
|
||||
// copilotModels) and not refused it, sent without a session, for a while;
|
||||
@@ -176,6 +260,24 @@ func (p Provider) ResolveAuto(ctx context.Context, model string) (context.Contex
|
||||
return context.WithValue(ctx, copilotAutoKey{}, a), a.Model, nil
|
||||
}
|
||||
|
||||
// AutoNext is the model Copilot's Auto picks now for a request ctx resolved
|
||||
// for it (ResolveAuto), "" for any other: once Retry has said a refused
|
||||
// pick is worth sending again, the one it goes as.
|
||||
func (p Provider) AutoNext(ctx context.Context) string {
|
||||
if _, ok := ctx.Value(copilotAutoKey{}).(copilotAutoSession); !ok || p.Account == nil || p.Account.auto == nil {
|
||||
return ""
|
||||
}
|
||||
a, err := p.Account.auto(p.Via(ctx))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return a.Model
|
||||
}
|
||||
|
||||
// CopilotRefusal is Copilot saying the account may not call the model it
|
||||
// was asked for, which Auto, having picked it, picks another for.
|
||||
func CopilotRefusal(body []byte) bool { return copilotNotServed.Match(body) }
|
||||
|
||||
// copilotAutoSign puts the Auto session on a request: the one ctx carries
|
||||
// when the body names its model, or, for a body asking for "auto" itself
|
||||
// (a test of the model), a session asked for here with the body's model
|
||||
|
||||
@@ -38,6 +38,8 @@ func (c *studentCopilot) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, `{"data":[
|
||||
{"id":"gpt-5-mini","name":"GPT-5 mini","model_picker_enabled":true,"policy":{"state":"disabled"},"supported_endpoints":["/responses"],"capabilities":{"type":"chat"}},
|
||||
{"id":"claude-sonnet-5","name":"Claude Sonnet 5","model_picker_category":"versatile","policy":{"state":"disabled"},"supported_endpoints":["/chat/completions","/v1/messages"],"capabilities":{"type":"chat"}}]}`)
|
||||
case r.URL.Path == "/auto":
|
||||
w.WriteHeader(404) // Auto v2 not offered: /models/session
|
||||
case r.URL.Path == "/models/session" && r.Method == "POST":
|
||||
c.sessions++
|
||||
c.hints = string(b)
|
||||
|
||||
@@ -63,6 +63,8 @@ func (c *student371) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// terms accepted, as they are for any account: the model is no
|
||||
// more served for it
|
||||
io.WriteString(w, `{"state":"enabled"}`)
|
||||
case "/auto":
|
||||
w.WriteHeader(404) // Auto v2 not offered: /models/session
|
||||
case "/models/session":
|
||||
if !c.auto {
|
||||
w.WriteHeader(404)
|
||||
|
||||
Reference in new issue
Block a user