Skip to content

Commit 0911974

Browse files
committed
wip(github): partial device flow implementation (billing display issue remains)
**Problem:** GitHub Copilot model picker shows "-" for all models instead of cost multipliers. Billing data only accessible with GitHub user tokens, not OAuth app tokens. **What Was Done:** - Created CopilotTokenStore for GitHub user token storage - Integrated with existing GitHubDeviceFlowService - Updated GitHubCopilotProvider to use stored tokens - Added token loading on app startup - Attempted copilot_internal/v2/token exchange (404 - endpoint unavailable) - Simplified to direct GitHub user token usage **What Works:** ✅ Device flow sign-in completes successfully ✅ GitHub user token obtained and stored ✅ Token persists across app restarts ✅ API calls use device flow token ✅ Build passes with no errors **What's Broken:** ❌ Billing data NOT appearing in model picker UI ❌ API returns billing data (confirmed via curl) but UI doesn't show it ❌ Cause unknown - needs investigation **Files Changed:** - NEW: Sources/APIFramework/CopilotTokenStore.swift - Token storage/retrieval - MODIFIED: Sources/APIFramework/Providers.swift - Added getAPIKey() helper - MODIFIED: Sources/UserInterface/Components/GitHubDeviceFlowSheet.swift - Stores token - MODIFIED: Sources/SAM/AppDelegate.swift - Loads tokens on startup - MODIFIED: Sources/ConfigurationSystem/GitHubDeviceFlow.swift - Added exchange (fails) - MODIFIED: Sources/APIFramework/ModelListManager.swift - From earlier session - MODIFIED: Sources/APIFramework/EndpointManager.swift - From earlier session - MODIFIED: Sources/UserInterface/Components/ModelPickerView.swift - From earlier session **Storage:** - Tokens: ~/Library/Application Support/SAM/copilot_tokens.json - Billing cache: ~/.config/sam/github_copilot_billing_cache.json **Next Steps:** 1. Investigate why billing data not appearing in UI 2. Simplify CopilotTokenStore (remove failed token exchange) 3. Fix log messages (currently misleading) 4. Test end-to-end flow **Handoff:** Complete context in ai-assisted/2026-01-08/0842/CONTINUATION_PROMPT.md **Notes:** - This is INCOMPLETE implementation - Billing data confirmed working via manual API tests - Issue is in UI display, not API communication - Related: Morning session created ModelListManager, identified root cause
1 parent b168679 commit 0911974

9 files changed

Lines changed: 553 additions & 269 deletions

File tree

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
// SPDX-License-Identifier: GPL-3.0-only
2+
// SPDX-FileCopyrightText: Copyright (c) 2025 Andrew Wyatt (Fewtarius)
3+
4+
import Foundation
5+
import Logging
6+
import ConfigurationSystem
7+
8+
/// Manages storage and refresh of Copilot tokens
9+
@MainActor
10+
public class CopilotTokenStore: ObservableObject {
11+
public static let shared = CopilotTokenStore()
12+
private let logger = Logger(label: "com.sam.copilot.tokenstore")
13+
14+
// Token storage
15+
@Published public var isSignedIn: Bool = false
16+
@Published public var username: String?
17+
18+
private var githubToken: String?
19+
private var copilotToken: CopilotTokenResponse?
20+
private var refreshTask: Task<Void, Never>?
21+
22+
private init() {
23+
// Try to load tokens on initialization
24+
try? loadTokens()
25+
}
26+
27+
/// Store GitHub user token and exchange for Copilot token
28+
public func setGitHubToken(_ token: String) async throws {
29+
githubToken = token
30+
31+
// Exchange for Copilot token using GitHubDeviceFlowService
32+
let deviceFlowService = GitHubDeviceFlowService()
33+
copilotToken = try await deviceFlowService.exchangeForCopilotToken(githubToken: token)
34+
35+
// Update published properties
36+
isSignedIn = true
37+
username = copilotToken?.username
38+
39+
// Start refresh timer
40+
startRefreshTimer()
41+
42+
// Save to disk
43+
try saveTokens()
44+
45+
// Notify that authentication succeeded
46+
NotificationCenter.default.post(name: .githubAuthenticationDidSucceed, object: nil)
47+
}
48+
49+
/// Store GitHub user token directly (no Copilot token exchange)
50+
/// GitHub user tokens from device flow already have billing access
51+
public func setGitHubTokenDirect(_ token: String) async {
52+
githubToken = token
53+
54+
// No Copilot token - we use GitHub token directly
55+
copilotToken = nil
56+
57+
// Update published properties
58+
isSignedIn = true
59+
username = nil // We don't have username without Copilot token response
60+
61+
// No refresh needed - GitHub tokens are long-lived
62+
refreshTask?.cancel()
63+
64+
// Save to disk
65+
try? saveTokens()
66+
67+
// Notify that authentication succeeded
68+
NotificationCenter.default.post(name: .githubAuthenticationDidSucceed, object: nil)
69+
}
70+
71+
/// Get current Copilot token, refreshing if needed
72+
/// Falls back to GitHub token if Copilot token not available
73+
public func getCopilotToken() async throws -> String {
74+
// If we have a Copilot token, use it (with refresh if needed)
75+
if let token = copilotToken {
76+
// Check if expired
77+
if token.isExpired() {
78+
logger.info("Copilot token expired, refreshing...")
79+
try await refreshCopilotToken()
80+
}
81+
return token.token
82+
}
83+
84+
// Fall back to GitHub token (from device flow)
85+
if let githubToken = githubToken {
86+
logger.debug("Using GitHub user token (no Copilot token available)")
87+
return githubToken
88+
}
89+
90+
// No token at all
91+
throw TokenStoreError.noToken
92+
}
93+
94+
/// Refresh Copilot token using stored GitHub token
95+
private func refreshCopilotToken() async throws {
96+
guard let githubToken = githubToken else {
97+
throw TokenStoreError.noGitHubToken
98+
}
99+
100+
let deviceFlowService = GitHubDeviceFlowService()
101+
copilotToken = try await deviceFlowService.exchangeForCopilotToken(githubToken: githubToken)
102+
username = copilotToken?.username
103+
try saveTokens()
104+
105+
logger.info("Copilot token refreshed successfully")
106+
}
107+
108+
/// Start automatic refresh timer
109+
private func startRefreshTimer() {
110+
refreshTask?.cancel()
111+
112+
refreshTask = Task {
113+
while !Task.isCancelled {
114+
guard let token = copilotToken else { return }
115+
116+
// Refresh 5 minutes before expiration
117+
let refreshInterval = max(token.refreshIn - 300, 60)
118+
try? await Task.sleep(nanoseconds: UInt64(refreshInterval) * 1_000_000_000)
119+
120+
if !Task.isCancelled {
121+
try? await refreshCopilotToken()
122+
}
123+
}
124+
}
125+
}
126+
127+
/// Clear all tokens (sign out)
128+
public func clearTokens() {
129+
refreshTask?.cancel()
130+
githubToken = nil
131+
copilotToken = nil
132+
isSignedIn = false
133+
username = nil
134+
try? deleteTokensFromDisk()
135+
136+
logger.info("Tokens cleared, user signed out")
137+
}
138+
139+
// MARK: - Persistence
140+
141+
private var tokensFilePath: URL {
142+
let configDir = FileManager.default.homeDirectoryForCurrentUser
143+
.appendingPathComponent(".config")
144+
.appendingPathComponent("sam")
145+
try? FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true)
146+
return configDir.appendingPathComponent("github_tokens.json")
147+
}
148+
149+
private func saveTokens() throws {
150+
let data = TokensStorage(
151+
githubToken: githubToken,
152+
copilotToken: copilotToken
153+
)
154+
let encoded = try JSONEncoder().encode(data)
155+
try encoded.write(to: tokensFilePath)
156+
logger.debug("Tokens saved to disk")
157+
}
158+
159+
public func loadTokens() throws {
160+
guard FileManager.default.fileExists(atPath: tokensFilePath.path) else {
161+
return
162+
}
163+
164+
let data = try Data(contentsOf: tokensFilePath)
165+
let storage = try JSONDecoder().decode(TokensStorage.self, from: data)
166+
167+
githubToken = storage.githubToken
168+
copilotToken = storage.copilotToken
169+
170+
// Update published properties
171+
if let token = copilotToken {
172+
isSignedIn = true
173+
username = token.username
174+
175+
// Start refresh if we have a valid token
176+
if !token.isExpired() {
177+
startRefreshTimer()
178+
logger.info("Loaded valid Copilot token from disk")
179+
} else {
180+
logger.warning("Loaded expired Copilot token, will need refresh")
181+
}
182+
}
183+
}
184+
185+
private func deleteTokensFromDisk() throws {
186+
if FileManager.default.fileExists(atPath: tokensFilePath.path) {
187+
try FileManager.default.removeItem(at: tokensFilePath)
188+
}
189+
}
190+
}
191+
192+
private struct TokensStorage: Codable {
193+
let githubToken: String?
194+
let copilotToken: CopilotTokenResponse?
195+
}
196+
197+
public enum TokenStoreError: LocalizedError {
198+
case noToken
199+
case noGitHubToken
200+
201+
public var errorDescription: String? {
202+
switch self {
203+
case .noToken:
204+
return "No Copilot token available. Please sign in with GitHub."
205+
case .noGitHubToken:
206+
return "No GitHub token available for refresh."
207+
}
208+
}
209+
}
210+
211+
// Notification for authentication success
212+
extension Notification.Name {
213+
public static let githubAuthenticationDidSucceed = Notification.Name("githubAuthenticationDidSucceed")
214+
}

Sources/APIFramework/EndpointManager.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,15 @@ public class EndpointManager: ObservableObject {
102102
return try await githubProvider.fetchModelCapabilities()
103103
}
104104

105+
/// Clear the GitHub Copilot capabilities cache to force a fresh fetch
106+
public func clearGitHubCopilotCapabilitiesCache() async throws {
107+
guard let githubProvider = providers.values.first(where: { $0.config.providerType == .githubCopilot }) as? GitHubCopilotProvider else {
108+
return
109+
}
110+
111+
await githubProvider.clearCapabilitiesCache()
112+
}
113+
105114
/// Get model capabilities (context sizes) from Gemini API Returns dictionary of modelId -> inputTokenLimit.
106115
public func getGeminiModelCapabilities() async throws -> [String: Int]? {
107116
guard let geminiProvider = providers.values.first(where: { $0.config.providerType == .gemini }) as? GeminiProvider else {

Sources/APIFramework/ModelListManager.swift

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,13 @@ public class ModelListManager: ObservableObject {
5757

5858
/// Initialize the manager with required dependencies
5959
public func initialize(endpointManager: EndpointManager) {
60+
logger.info("Initializing ModelListManager with EndpointManager")
6061
self.endpointManager = endpointManager
6162

62-
// Initial load
63+
// Trigger initial refresh with a small delay to allow providers to finish loading
6364
Task {
64-
await refresh()
65+
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
66+
await refresh(force: true)
6567
}
6668
}
6769

@@ -127,7 +129,11 @@ public class ModelListManager: ObservableObject {
127129

128130
do {
129131
// Fetch GitHub Copilot model capabilities (including billing) BEFORE loading models
132+
// FORCE fresh fetch to ensure billing data is populated (not from capabilities cache)
130133
do {
134+
// Clear the static capabilities cache to force a fresh API call with billing data
135+
// This is needed because the cache check happens before billing data is populated
136+
_ = try await endpointManager.clearGitHubCopilotCapabilitiesCache()
131137
_ = try await endpointManager.getGitHubCopilotModelCapabilities()
132138
logger.debug("Fetched GitHub Copilot capabilities with billing info")
133139
} catch {
@@ -136,6 +142,7 @@ public class ModelListManager: ObservableObject {
136142

137143
// Get models from endpoint manager
138144
let modelsResponse = try await endpointManager.getAvailableModels()
145+
logger.debug("EndpointManager getAvailableModels returned \(modelsResponse.data.count) model(s)")
139146

140147
// Deduplicate models based on base model ID
141148
var seenBaseIds = Set<String>()

Sources/APIFramework/Providers.swift

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -714,6 +714,27 @@ public class GitHubCopilotProvider: AIProvider, ObservableObject {
714714
loadQuotaCache()
715715
}
716716

717+
/// Get API key - tries Copilot token first, falls back to config API key
718+
/// This allows both device flow (with billing data) and manual API key (without billing data)
719+
private func getAPIKey() async throws -> String {
720+
// Try Copilot token first (preferred - has billing data)
721+
do {
722+
let token = try await CopilotTokenStore.shared.getCopilotToken()
723+
logger.debug("Using Copilot token from device flow (has billing data)")
724+
return token
725+
} catch {
726+
logger.debug("Copilot token unavailable: \(error.localizedDescription)")
727+
}
728+
729+
// Fall back to manual API key (no billing data, but still works)
730+
guard let apiKey = config.apiKey else {
731+
throw ProviderError.authenticationFailed("GitHub Copilot API key not configured. Please sign in with GitHub or provide an API key.")
732+
}
733+
734+
logger.debug("Using manual API key (billing data not available)")
735+
return apiKey
736+
}
737+
717738
/// Fetch model capabilities from GitHub Copilot /models API Returns dictionary of modelId -> max_input_tokens (context size) **Why needed**: GitHub Copilot doesn't include model capabilities in main API responses.
718739
public func fetchModelCapabilities() async throws -> [String: Int] {
719740
/// Check cache first.
@@ -726,9 +747,7 @@ public class GitHubCopilotProvider: AIProvider, ObservableObject {
726747

727748
logger.info("Fetching model capabilities from GitHub Copilot /models API")
728749

729-
guard let apiKey = config.apiKey else {
730-
throw ProviderError.authenticationFailed("GitHub Copilot API key not configured")
731-
}
750+
let apiKey = try await getAPIKey()
732751

733752
let baseURL = config.baseURL ?? "https://api.githubcopilot.com"
734753
let modelsURL = "\(baseURL)/models"
@@ -790,6 +809,11 @@ public class GitHubCopilotProvider: AIProvider, ObservableObject {
790809
let decoder = JSONDecoder()
791810
let modelsResponse = try decoder.decode(GitHubCopilotModelsResponse.self, from: data)
792811

812+
/// DEBUG: Log raw JSON for first model to see what API is returning
813+
if let firstModelData = String(data: data, encoding: .utf8)?.prefix(2000) {
814+
logger.debug("RAW API RESPONSE (first 2000 chars): \(firstModelData)")
815+
}
816+
793817
/// Build capabilities dictionary.
794818
var capabilities: [String: Int] = [:]
795819
var billingInfo: [String: (isPremium: Bool, multiplier: Double?)] = [:]
@@ -798,8 +822,13 @@ public class GitHubCopilotProvider: AIProvider, ObservableObject {
798822
if let maxInputTokens = model.maxInputTokens {
799823
capabilities[model.id] = maxInputTokens
800824

801-
/// Store billing information
825+
/// Store billing information using computed properties
802826
billingInfo[model.id] = (isPremium: model.isPremium, multiplier: model.premiumMultiplier)
827+
828+
/// DEBUG: Log billing data for first few models
829+
if billingInfo.count <= 5 {
830+
logger.debug("BILLING: \(model.id) - isPremium=\(model.isPremium), multiplier=\(model.premiumMultiplier?.description ?? "nil"), raw_billing=\(model.billing != nil ? "present" : "nil")")
831+
}
803832
}
804833
}
805834

@@ -841,6 +870,13 @@ public class GitHubCopilotProvider: AIProvider, ObservableObject {
841870
throw ProviderError.networkError("Failed to fetch model capabilities: \(error.localizedDescription)")
842871
}
843872
}
873+
874+
/// Clear the capabilities cache to force a fresh fetch next time
875+
public func clearCapabilitiesCache() {
876+
Self.modelCapabilitiesCache = nil
877+
Self.modelCapabilitiesCacheTime = nil
878+
logger.debug("Cleared capabilities cache - next fetch will be fresh from API")
879+
}
844880

845881
/// Load billing cache from disk
846882
private func loadBillingCache() {
@@ -998,9 +1034,7 @@ public class GitHubCopilotProvider: AIProvider, ObservableObject {
9981034
}
9991035
lastRequestTime = Date()
10001036

1001-
guard let apiKey = config.apiKey else {
1002-
throw ProviderError.authenticationFailed("GitHub Copilot API key not configured")
1003-
}
1037+
let apiKey = try await getAPIKey()
10041038

10051039
let urlRequest = try await createGitHubCopilotRequest(request, apiKey: apiKey, streaming: true)
10061040

@@ -1189,9 +1223,7 @@ public class GitHubCopilotProvider: AIProvider, ObservableObject {
11891223
let requestId = UUID().uuidString
11901224
logger.debug("Processing GitHub Copilot API request [req:\(requestId.prefix(8))], streaming: \(streaming)")
11911225

1192-
guard let apiKey = config.apiKey else {
1193-
throw ProviderError.authenticationFailed("GitHub Copilot API key not configured")
1194-
}
1226+
let apiKey = try await getAPIKey()
11951227

11961228
let urlRequest = try await createGitHubCopilotRequest(request, apiKey: apiKey, streaming: streaming)
11971229

@@ -1854,9 +1886,7 @@ public class GitHubCopilotProvider: AIProvider, ObservableObject {
18541886
}
18551887
lastRequestTime = Date()
18561888

1857-
guard let apiKey = config.apiKey else {
1858-
throw ProviderError.authenticationFailed("GitHub Copilot API key not configured")
1859-
}
1889+
let apiKey = try await getAPIKey()
18601890

18611891
let urlRequest = try await createResponsesAPIRequest(request, apiKey: apiKey)
18621892

@@ -2338,11 +2368,8 @@ public class GitHubCopilotProvider: AIProvider, ObservableObject {
23382368
}
23392369

23402370
public func validateConfiguration() async throws -> Bool {
2341-
guard let apiKey = config.apiKey, !apiKey.isEmpty else {
2342-
throw ProviderError.authenticationFailed("GitHub Copilot API key is required")
2343-
}
2344-
2345-
/// FUTURE FEATURE: Real API validation Currently: Basic check (non-empty key) Future: Make test API call to validate key actually works Reason not implemented: Validation adds latency to preferences UI Alternative: Validation happens on first API call (error shows invalid key).
2371+
// Try to get API key (either Copilot token or manual key)
2372+
_ = try await getAPIKey()
23462373
return true
23472374
}
23482375

0 commit comments

Comments
 (0)