1package provider
2
3import (
4 "context"
5 "fmt"
6 "regexp"
7 "strings"
8)
9
10type copilotRequestKey struct{}
11type copilotRequestMetadata struct {
12 initiator string
13 vision bool
14}
15
16type copilotClient struct {
17 router *modelRouter
18}
19
20func (c *copilotClient) Name() string { return "github-copilot" }
21
22// SetModelMetadata lets runtime-owned model switches reach the wire clients.
23func (c *copilotClient) SetModelMetadata(model Model) { c.router.SetModelMetadata(model) }
24
25func (c *copilotClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
26 metadata := copilotRequestMetadata{initiator: "user"}
27 if len(req.Messages) > 0 && req.Messages[len(req.Messages)-1].Role != RoleUser {
28 metadata.initiator = "agent"
29 }
30 for _, message := range req.Messages {
31 if message.Role == RoleUser || message.Role == RoleTool {
32 metadata.vision = metadata.vision || copilotHasImages(message.Content)
33 }
34 }
35 // One catalog lookup decides both reasoning and route.
36 model, findErr := FindModel(c.Name(), req.Model)
37 tagged := findErr == nil && model.API != ""
38 api := copilotModelAPI(req.Model)
39 if tagged {
40 api = model.API
41 }
42 // Chat Completions endpoints perform their own thinking; they reject
43 // reasoning_effort.
44 if api == APICompletions {
45 req.Reasoning = ""
46 }
47 ctx = context.WithValue(ctx, copilotRequestKey{}, metadata)
48 if tagged {
49 return c.router.Stream(ctx, req)
50 }
51 // Use family routing for custom models that have not supplied an API tag.
52 routed := c.router.byAPI[api]
53 if routed == nil {
54 return nil, fmt.Errorf("provider %q model %q has no client for wire API %q", c.Name(), req.Model, api)
55 }
56 return routed.Stream(ctx, req)
57}
58
59func copilotHasImages(blocks []Content) bool {
60 for _, block := range blocks {
61 switch b := block.(type) {
62 case ImageBlock:
63 return true
64 case ToolResultBlock:
65 if copilotHasImages(b.Content) {
66 return true
67 }
68 }
69 }
70 return false
71}
72
73func copilotModelAPI(id string) string {
74 switch {
75 case strings.HasPrefix(id, "claude-"):
76 return APIAnthropicMessages
77 case strings.HasPrefix(id, "gpt-4"), strings.HasPrefix(id, "grok-code-"):
78 // Legacy chat-only families keep Chat Completions.
79 return APICompletions
80 case strings.HasPrefix(id, "gpt-"), strings.HasPrefix(id, "grok-"), strings.HasPrefix(id, "mai-"), strings.HasPrefix(id, "oswe"):
81 return APIResponses
82 default:
83 return APICompletions
84 }
85}
86
87func configureCopilotModel(m *Model) {
88 switch m.API {
89 case "":
90 m.API = copilotModelAPI(m.ID)
91 case "anthropic":
92 // Legacy models.json api values map onto Copilot's wire protocols.
93 m.API = APIAnthropicMessages
94 case "openai":
95 m.API = APICompletions
96 }
97 // Claude Haiku, 4.5 and 4.6 use explicit budgets: the canonical Anthropic
98 // metadata (usesAdaptiveThinking) does not treat 4.6 as adaptive. Newer or
99 // unknown Claude ids keep the adaptive default; explicit metadata wins.
100 if m.API == APIAnthropicMessages && !usesExplicitThinkingBudget(m.ID) {
101 m.AdaptiveThinking = true
102 }
103 // An explicit mapping (for example from models.json) always wins.
104 if m.ReasoningLevelMap != nil {
105 return
106 }
107 if m.API == APICompletions {
108 m.ReasoningLevelMap = map[string]string{"minimum": "", "low": "", "medium": "", "high": "", "xhigh": "", "max": ""}
109 }
110 // Responses defaults expose xhigh, but these models only support low/medium/high.
111 if m.ID == "gpt-5-mini" || m.ID == "grok-4.5" || strings.HasPrefix(m.ID, "mai-") {
112 m.ReasoningLevelMap = map[string]string{"xhigh": "", "max": ""}
113 }
114}
115
116func usesExplicitThinkingBudget(id string) bool {
117 if strings.HasPrefix(id, "claude-haiku-") {
118 return true
119 }
120 // Claude 4 through 4.6 predate adaptive thinking (4.7 is the first).
121 return copilotLegacyClaudeVersion.MatchString(id)
122}
123
124var copilotLegacyClaudeVersion = regexp.MustCompile(`-4(\.[0-6])?$`)