Skip to content
163 changes: 163 additions & 0 deletions GraphcodeKit/Sources/Domain/NodModelCatalog.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import Foundation

/// The models Nod offers on each engine, and which one a loop gets when nobody picked.
///
/// Settings › Agents › Nod and the new-loop agent menu both read this, and so does the
/// launch path, so a loop never starts on a model the menu would not have offered. A
/// stored choice that is no longer in the list (the lineup moved on, or the engine was
/// switched) falls back to the engine's default for that loop type rather than being
/// passed through to fail at launch.
public struct NodModel: Equatable, Hashable, Sendable, Identifiable {
public enum Family: String, Sendable {
case claude
case gpt
case gemini
}

/// The id the engine takes: an alias on the Claude Agent SDK, which keeps resolving to
/// the current model in its class, and a versioned id on the Copilot SDK.
public var id: String
public var displayName: String
public var family: Family
public var tier: ModelTier

public init(id: String, displayName: String, family: Family, tier: ModelTier) {
self.id = id
self.displayName = displayName
self.family = family
self.tier = tier
}
}

public enum NodModelCatalog {
public static func models(for engine: NodEngine) -> [NodModel] {
switch engine {
case .claudeAgentSDK:
return [
NodModel(id: "opus", displayName: "Opus", family: .claude, tier: .capable),
NodModel(id: "sonnet", displayName: "Sonnet", family: .claude, tier: .standard),
NodModel(id: "haiku", displayName: "Haiku", family: .claude, tier: .fast),
]
case .copilotSDK:
// Read off `copilot help config` at 1.0.84, the list the Copilot SDK shares.
return [
NodModel(id: "gpt-6-sol", displayName: "GPT-6 Sol", family: .gpt, tier: .standard),
NodModel(
id: "gpt-5.6-luna", displayName: "GPT-5.6 Luna", family: .gpt, tier: .fast),
NodModel(
id: "claude-opus-5.5", displayName: "Claude Opus 5.5", family: .claude,
tier: .capable),
NodModel(
id: "claude-sonnet-5", displayName: "Claude Sonnet 5", family: .claude,
tier: .standard),
NodModel(
id: "gemini-3.8-flash", displayName: "Gemini 3.8 Flash", family: .gemini,
tier: .fast),
]
}
}

/// "Opus · Sonnet · Haiku" or "GPT · Claude · Gemini", the line under each engine card.
public static func familySummary(for engine: NodEngine) -> String {
switch engine {
case .claudeAgentSDK:
return models(for: engine).map(\.displayName).joined(separator: " · ")
case .copilotSDK:
return "GPT · Claude · Gemini"
}
}

public static func model(id: String, engine: NodEngine) -> NodModel? {
models(for: engine).first { $0.id == id }
}

/// The first model of `tier` on `engine`; every engine offers all three tiers.
public static func model(for tier: ModelTier, engine: NodEngine) -> NodModel {
models(for: engine).first { $0.tier == tier } ?? models(for: engine)[0]
}

/// What a loop type runs on when Settings has no choice for it: the design's Main
/// Sonnet, Goal Opus, Timed Haiku, Turn Sonnet, Composite Opus.
public static func defaultTier(for loopType: LoopType) -> ModelTier {
switch loopType {
case .sketch, .turnBased: return .standard
case .goalBased, .composite: return .capable
case .timeBased: return .fast
}
}

public static let defaultCompositeChildTier = ModelTier.standard
public static let defaultEvaluatorTier = ModelTier.fast
}

extension NodSettings {
/// The model a new `loopType` loop starts on. An explicit `tier` (the new-loop menu's
/// pick) wins; otherwise the per-type setting, if it still names a model this engine
/// offers; otherwise the type's default.
public func resolvedModel(for loopType: LoopType, tier: ModelTier? = nil) -> NodModel {
if let tier { return NodModelCatalog.model(for: tier, engine: engine) }
return stored(model(for: loopType))
?? NodModelCatalog.model(for: NodModelCatalog.defaultTier(for: loopType), engine: engine)
}

public var resolvedCompositeChildModel: NodModel {
stored(compositeChildModel)
?? NodModelCatalog.model(for: NodModelCatalog.defaultCompositeChildTier, engine: engine)
}

public var resolvedGoalEvaluatorModel: NodModel {
stored(goalEvaluatorModel)
?? NodModelCatalog.model(for: NodModelCatalog.defaultEvaluatorTier, engine: engine)
}

/// Sets the per-type model; choosing the type's default clears the entry, so a later
/// change to the defaults reaches it.
public mutating func setModel(_ model: NodModel, for loopType: LoopType) {
let isDefault =
model
== NodModelCatalog.model(for: NodModelCatalog.defaultTier(for: loopType), engine: engine)
modelsByLoopType[loopType.rawValue] = isDefault ? nil : model.id
}

/// Switching engines drops model choices the new engine cannot run. Existing loops keep
/// the engine they started on; this only changes what new loops get.
public mutating func switchEngine(to newEngine: NodEngine) {
guard newEngine != engine else { return }
engine = newEngine
modelsByLoopType = modelsByLoopType.filter {
NodModelCatalog.model(id: $0.value, engine: newEngine) != nil
}
if let child = compositeChildModel, NodModelCatalog.model(id: child, engine: newEngine) == nil {
compositeChildModel = nil
}
if let evaluator = goalEvaluatorModel,
NodModelCatalog.model(id: evaluator, engine: newEngine) == nil
{
goalEvaluatorModel = nil
}
}

/// Adds a shell pattern, trimmed, ignoring blanks and duplicates. Returns whether the
/// list changed.
@discardableResult
public mutating func addAllowlistPattern(_ pattern: String) -> Bool {
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, !shellAllowlist.contains(trimmed) else { return false }
shellAllowlist.append(trimmed)
return true
}

public mutating func removeAllowlistPattern(_ pattern: String) {
shellAllowlist.removeAll { $0 == pattern }
}

/// A negative or non-finite cap is stored as no cap rather than as a value the runtime
/// would have to second-guess.
public mutating func setSpendCap(_ dollars: Double) {
spendCapUSD = dollars.isFinite && dollars > 0 ? (dollars * 100).rounded() / 100 : 0
}

private func stored(_ id: String?) -> NodModel? {
id.flatMap { NodModelCatalog.model(id: $0, engine: engine) }
}
}
8 changes: 7 additions & 1 deletion GraphcodeKit/Sources/Domain/NodSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ public struct NodSettings: Codable, Equatable, Sendable {
/// Per-run cap for unattended loops (timed and composite children), in dollars; 0 is no
/// cap. Copilot reports premium requests instead and is capped by its plan.
public var spendCapUSD: Double
/// MCP servers from the project's `.mcp.json` switched off for Nod, by name. The
/// built-in graphcode server is always on and never listed here.
public var disabledMCPServers: [String]

public init(
engine: NodEngine = .claudeAgentSDK,
Expand All @@ -52,7 +55,8 @@ public struct NodSettings: Codable, Equatable, Sendable {
editsOutsideWorktree: Ask = .never,
messagesOtherLoops: MessagePolicy = .draftForMe,
shellAllowlist: [String] = [],
spendCapUSD: Double = 2
spendCapUSD: Double = 2,
disabledMCPServers: [String] = []
) {
self.engine = engine
self.modelsByLoopType = modelsByLoopType
Expand All @@ -65,6 +69,7 @@ public struct NodSettings: Codable, Equatable, Sendable {
self.messagesOtherLoops = messagesOtherLoops
self.shellAllowlist = shellAllowlist
self.spendCapUSD = spendCapUSD
self.disabledMCPServers = disabledMCPServers
}

public init(from decoder: Decoder) throws {
Expand All @@ -84,6 +89,7 @@ public struct NodSettings: Codable, Equatable, Sendable {
messagesOtherLoops = try value(.messagesOtherLoops, defaults.messagesOtherLoops)
shellAllowlist = try value(.shellAllowlist, defaults.shellAllowlist)
spendCapUSD = try value(.spendCapUSD, defaults.spendCapUSD)
disabledMCPServers = try value(.disabledMCPServers, defaults.disabledMCPServers)
}

public func model(for loopType: LoopType) -> String? {
Expand Down
195 changes: 195 additions & 0 deletions graphcode/Sources/Clients/CopilotDeviceFlow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import Foundation

/// GitHub's OAuth device flow, for Nod's Copilot engine: ask for a code, show it, poll
/// until the person enters it at github.com/login/device, then read the account's
/// Copilot plan so the success state can say what the seat buys.
///
/// The token goes to the Keychain (`NodCredential.githubCopilot`). Every request goes
/// through `transport`, so tests replay GitHub's documented responses without a network.
struct CopilotDeviceFlow: Sendable {
typealias Transport = @Sendable (URLRequest) async throws -> (Data, Int)

struct DeviceCode: Equatable, Sendable {
var deviceCode: String
var userCode: String
var verificationURL: URL
var expiresAt: Date
var interval: Duration
}

struct Account: Equatable, Sendable {
var login: String
/// GitHub's plan word, e.g. `business`; `planName` is the display form.
var plan: String?
var modelCount: Int?
var premiumRequestsUsed: Int?
var premiumRequestsLimit: Int?

var planName: String? {
plan.map { "Copilot " + $0.replacingOccurrences(of: "_", with: " ").capitalized }
}
}

enum Failure: Error, Equatable {
/// This build carries no GitHub OAuth app client id.
case notConfigured
case expired
case denied
case unexpected(String)
}

var clientID: String?
var transport: Transport
var sleep: @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) }
var now: @Sendable () -> Date = { Date() }

static let scope = "read:user"

static var bundledClientID: String? {
(Bundle.main.object(forInfoDictionaryKey: "GraphCodeGitHubClientID") as? String)
.flatMap { $0.isEmpty ? nil : $0 }
}

static let live = CopilotDeviceFlow(clientID: bundledClientID) { request in
let (data, response) = try await URLSession.shared.data(for: request)
return (data, (response as? HTTPURLResponse)?.statusCode ?? 0)
}

func requestCode() async throws -> DeviceCode {
guard let clientID else { throw Failure.notConfigured }
let json = try await post(
"https://github.com/login/device/code", ["client_id": clientID, "scope": Self.scope])
guard
let deviceCode = json["device_code"] as? String,
let userCode = json["user_code"] as? String,
let uri = (json["verification_uri"] as? String).flatMap(URL.init(string:)),
let expiresIn = json["expires_in"] as? Int
else { throw Failure.unexpected(Self.describe(json)) }
return DeviceCode(
deviceCode: deviceCode, userCode: userCode, verificationURL: uri,
expiresAt: now().addingTimeInterval(TimeInterval(expiresIn)),
interval: .seconds(json["interval"] as? Int ?? 5))
}

/// Polls until GitHub hands over a token, the code expires, or the person declines.
/// `slow_down` adds five seconds to the interval, as GitHub asks.
func pollForToken(_ code: DeviceCode) async throws -> String {
guard let clientID else { throw Failure.notConfigured }
var interval = code.interval
while true {
try await sleep(interval)
guard now() < code.expiresAt else { throw Failure.expired }
let json = try await post(
"https://github.com/login/oauth/access_token",
[
"client_id": clientID, "device_code": code.deviceCode,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
])
if let token = json["access_token"] as? String { return token }
switch json["error"] as? String {
case "authorization_pending":
continue
case "slow_down":
interval = .seconds(json["interval"] as? Int ?? Int(interval.components.seconds) + 5)
case "expired_token":
throw Failure.expired
case "access_denied":
throw Failure.denied
default:
throw Failure.unexpected(Self.describe(json))
}
}
}

/// The login, then the Copilot plan, premium requests and model count. Only the login
/// is required: the plan endpoints are GitHub's internal ones and a missing answer
/// leaves that line off the success state rather than failing a sign-in that worked.
func account(token: String) async throws -> Account {
let user = try await get("https://api.github.com/user", token: "token \(token)")
guard let login = user["login"] as? String else {
throw Failure.unexpected(Self.describe(user))
}
var account = Account(login: login)
if let copilot = try? await get(
"https://api.github.com/copilot_internal/user", token: "token \(token)")
{
account.plan = copilot["copilot_plan"] as? String
if let premium = (copilot["quota_snapshots"] as? [String: Any])?["premium_interactions"]
as? [String: Any],
premium["unlimited"] as? Bool != true,
let entitlement = premium["entitlement"] as? Int,
let remaining = premium["remaining"] as? Int
{
account.premiumRequestsLimit = entitlement
account.premiumRequestsUsed = max(0, entitlement - remaining)
}
}
if let session = try? await get(
"https://api.github.com/copilot_internal/v2/token", token: "token \(token)"),
let sessionToken = session["token"] as? String,
let models = try? await get(
"https://api.githubcopilot.com/models", token: "Bearer \(sessionToken)"),
let data = models["data"] as? [[String: Any]]
{
account.modelCount = data.filter { $0["model_picker_enabled"] as? Bool ?? true }.count
}
return account
}

private func post(_ url: String, _ form: [String: String]) async throws -> [String: Any] {
var request = URLRequest(url: URL(string: url)!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
var components = URLComponents()
components.queryItems = form.sorted { $0.key < $1.key }.map {
URLQueryItem(name: $0.key, value: $0.value)
}
request.httpBody = Data((components.percentEncodedQuery ?? "").utf8)
return try await send(request)
}

private func get(_ url: String, token: String) async throws -> [String: Any] {
var request = URLRequest(url: URL(string: url)!)
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(token, forHTTPHeaderField: "Authorization")
return try await send(request)
}

private func send(_ request: URLRequest) async throws -> [String: Any] {
let (data, status) = try await transport(request)
let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] ?? [:]
// The device-flow errors arrive as 200s with an `error` field; anything else non-2xx
// is a real failure.
guard (200..<300).contains(status) || json["error"] != nil else {
throw Failure.unexpected("HTTP \(status)")
}
return json
}

private static func describe(_ json: [String: Any]) -> String {
(json["error_description"] as? String) ?? (json["error"] as? String) ?? "unexpected reply"
}
}

/// Display helpers for the sign-in card, kept apart from the view so tests pin them.
enum CopilotSignInText {
/// "14:12" — minutes and seconds until the code expires, never negative.
static func countdown(until expiry: Date, now: Date) -> String {
let remaining = max(0, Int(expiry.timeIntervalSince(now).rounded(.down)))
return String(format: "%d:%02d", remaining / 60, remaining % 60)
}

/// "7 models available · premium requests 212 / 300 this month", leaving out what
/// GitHub did not say.
static func accountDetail(_ account: CopilotDeviceFlow.Account) -> String {
var parts: [String] = []
if let count = account.modelCount {
parts.append("\(count) model\(count == 1 ? "" : "s") available")
}
if let used = account.premiumRequestsUsed, let limit = account.premiumRequestsLimit {
parts.append("premium requests \(used) / \(limit) this month")
}
return parts.joined(separator: " · ")
}
}
Loading
Loading