diff --git a/GraphcodeKit/Sources/Domain/NodModelCatalog.swift b/GraphcodeKit/Sources/Domain/NodModelCatalog.swift index 8494e871..cd8d6545 100644 --- a/GraphcodeKit/Sources/Domain/NodModelCatalog.swift +++ b/GraphcodeKit/Sources/Domain/NodModelCatalog.swift @@ -12,6 +12,7 @@ public struct NodModel: Equatable, Hashable, Sendable, Identifiable { case claude case gpt case gemini + case other } /// The id the engine takes: an alias on the Claude Agent SDK, which keeps resolving to @@ -39,24 +40,55 @@ public enum NodModelCatalog { 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), - ] + let builtIn = copilotModels + return builtIn + + discoveredCopilotModels.filter { model in !builtIn.contains { $0.id == model.id } } } } + /// Read off `copilot help config` at 1.0.89. The first model of each tier is that tier's + /// default, so the order of the first five matters; the rest follow the CLI's order. + static let copilotModels: [NodModel] = + [ + "gpt-6-sol", "gpt-5.6-luna", "claude-opus-5.5", "claude-sonnet-5", "gemini-3.8-flash", + "claude-fable-5.1", "claude-fable-5", "claude-opus-5", "claude-opus-4.8", + "claude-opus-4.8-fast", "claude-opus-4.7", "claude-sonnet-4.6", "claude-haiku-4.5", + "gpt-6-luna", "gpt-6-astra", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.5", "gpt-5.4", + "gpt-5.4-mini", "gpt-5.3-codex", "gpt-5-mini", "mai-code-1.1-flash", "gemini-3.7-flash", + "gemini-3.6-flash", "gemini-3.5-flash", "grok-4.5", "kimi-k3", "kimi-k2.7-code", "auto", + ].map { copilotModel(id: $0) } + + /// Models the Copilot SDK reported for the signed-in account (`graphcode-nod + /// --list-models`) that the built-in list lacks. Set by the app; empty until it has asked. + nonisolated(unsafe) public static var discoveredCopilotModels: [NodModel] = [] + + /// A Copilot model from its id alone: the SDK's list and the CLI's both give ids, and the + /// tier is what a loop type's default needs. + public static func copilotModel(id: String, name: String? = nil) -> NodModel { + let family: NodModel.Family = + id.hasPrefix("claude") + ? .claude + : id.hasPrefix("gpt") + ? .gpt + : id.hasPrefix("gemini") ? .gemini : .other + let tier: ModelTier = + ["haiku", "mini", "flash", "luna", "fast"].contains { id.contains($0) } + ? .fast + : ["opus", "fable"].contains { id.contains($0) } ? .capable : .standard + return NodModel( + id: id, displayName: name ?? copilotDisplayName(id), family: family, tier: tier) + } + + /// `claude-sonnet-5` → "Claude Sonnet 5", `gpt-6-sol` → "GPT-6 Sol". + static func copilotDisplayName(_ id: String) -> String { + if id == "auto" { return "Auto" } + var words = id.split(separator: "-").map(String.init) + if words.first == "gpt", words.count > 1 { words[0...1] = ["GPT-\(words[1])"] } + return words.map { word in + ["mai"].contains(word) ? word.uppercased() : word.prefix(1).uppercased() + word.dropFirst() + }.joined(separator: " ") + } + /// "Opus · Sonnet · Haiku" or "GPT · Claude · Gemini", the line under each engine card. public static func familySummary(for engine: NodEngine) -> String { switch engine { diff --git a/GraphcodeKit/Sources/Domain/NodSettings.swift b/GraphcodeKit/Sources/Domain/NodSettings.swift index e0361760..5757eeb1 100644 --- a/GraphcodeKit/Sources/Domain/NodSettings.swift +++ b/GraphcodeKit/Sources/Domain/NodSettings.swift @@ -51,7 +51,7 @@ public struct NodSettings: Codable, Equatable, Sendable { goalEvaluatorModel: String? = nil, shell: Ask = .ask, network: Ask = .ask, - editsInWorktree: EditPolicy = .reviewHunks, + editsInWorktree: EditPolicy = .auto, editsOutsideWorktree: Ask = .never, messagesOtherLoops: MessagePolicy = .draftForMe, shellAllowlist: [String] = [], diff --git a/NodRuntime/src/copilotEngine.ts b/NodRuntime/src/copilotEngine.ts index be51774f..15f4e41f 100644 --- a/NodRuntime/src/copilotEngine.ts +++ b/NodRuntime/src/copilotEngine.ts @@ -11,7 +11,16 @@ import { type SessionConfig, type SessionEvent, } from "@github/copilot-sdk"; -import type { Engine, EngineFailure, EngineSession, EngineStart, ToolRequest, TurnCallbacks, TurnResult } from "./engine"; +import type { + Engine, + EngineFailure, + EngineSession, + EngineStart, + ModelListing, + ToolRequest, + TurnCallbacks, + TurnResult, +} from "./engine"; import { serverName } from "./mcp"; import { copilotGraphcodeTools, copilotMcpServers, copilotToolPrefix } from "./mcpServers"; import type { NodAttachment } from "./protocol"; @@ -154,6 +163,24 @@ export class CopilotEngine implements Engine { } } + /** The SDK's own list for this account, minus models its policy has switched off. */ + async listModels(): Promise { + const client = this.makeClient(process.cwd()); + await client.start(); + try { + const models = await client.listModels(); + return models + .filter((model) => model.policy?.state !== "disabled") + .map((model) => ({ + id: model.id, + name: model.name, + ...(model.billing?.multiplier !== undefined ? { multiplier: model.billing.multiplier } : {}), + })); + } finally { + await client.stop(); + } + } + async close(): Promise { await this.session?.disconnect().catch(() => {}); await this.client?.stop().catch(() => {}); diff --git a/NodRuntime/src/engine.ts b/NodRuntime/src/engine.ts index 0820871a..05f52369 100644 --- a/NodRuntime/src/engine.ts +++ b/NodRuntime/src/engine.ts @@ -72,6 +72,14 @@ export interface TurnResult { * adds — the event log, staging, the gate, the goal — sits above this and never * branches on which engine it has. */ +/** One model the signed-in account can pick, as `graphcode-nod --list-models` prints it. */ +export interface ModelListing { + id: string; + name: string; + /** Premium-request multiplier, when the engine bills that way. */ + multiplier?: number; +} + export interface Engine { readonly kind: NodEngineKind; start(options: EngineStart): Promise; @@ -83,5 +91,7 @@ export interface Engine { compact(callbacks: TurnCallbacks): Promise; /** A one-shot question with no tools and no conversation — the goal judge and `-p`. */ ask(prompt: string, model?: string): Promise; + /** The models this account may use, when the engine can say; undefined when it can't. */ + listModels?(): Promise; close(): Promise; } diff --git a/NodRuntime/src/main.ts b/NodRuntime/src/main.ts index 322d9b58..2a507c5c 100644 --- a/NodRuntime/src/main.ts +++ b/NodRuntime/src/main.ts @@ -22,7 +22,8 @@ const USAGE = `graphcode-nod --node --cwd [--engine claude|copilot] [--loop-type main|goal|timed|turn|composite] [--goal-file ] [--briefing ] [--resume ] [--inherit ] [--prompt ] [--unattended] -graphcode-nod -p [--engine claude|copilot] [--model ]`; +graphcode-nod -p [--engine claude|copilot] [--model ] +graphcode-nod --list-models [--engine copilot] # JSON: the models this account may use`; const loopTypes = new Set(["main", "goal", "timed", "turn", "composite"]); @@ -58,6 +59,7 @@ async function main(argv: string[]): Promise { inherit: { type: "string" }, prompt: { type: "string" }, print: { type: "string", short: "p" }, + "list-models": { type: "boolean" }, unattended: { type: "boolean" }, "exit-when-idle": { type: "boolean" }, help: { type: "boolean", short: "h" }, @@ -72,6 +74,17 @@ async function main(argv: string[]): Promise { const engineKind = (values.engine ?? settings.engine) as NodEngineKind; if (engineKind !== "claude" && engineKind !== "copilot") throw new Error(`unknown engine ${engineKind}`); + if (values["list-models"]) { + const engine = makeEngine(engineKind); + try { + const models = engine.listModels ? await engine.listModels() : undefined; + process.stdout.write(JSON.stringify({ engine: engineKind, models: models ?? null }) + "\n"); + return 0; + } finally { + await engine.close(); + } + } + if (values.print !== undefined) { const engine = makeEngine(engineKind); try { diff --git a/NodRuntime/src/settings.ts b/NodRuntime/src/settings.ts index 45d82a73..62cc504b 100644 --- a/NodRuntime/src/settings.ts +++ b/NodRuntime/src/settings.ts @@ -27,7 +27,7 @@ export const defaultSettings: NodSettings = { modelsByLoopType: {}, shell: "ask", network: "ask", - editsInWorktree: "reviewHunks", + editsInWorktree: "auto", editsOutsideWorktree: "never", messagesOtherLoops: "draftForMe", shellAllowlist: [], diff --git a/NodRuntime/test/listModels.test.ts b/NodRuntime/test/listModels.test.ts new file mode 100644 index 00000000..ab0c7242 --- /dev/null +++ b/NodRuntime/test/listModels.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from "bun:test"; +import type { CopilotClient } from "@github/copilot-sdk"; +import { CopilotEngine } from "../src/copilotEngine"; +import { defaultSettings } from "../src/settings"; + +test("the Copilot engine lists the account's models, without ones its policy switched off", async () => { + let stopped = false; + const client = { + start: async () => {}, + stop: async () => { + stopped = true; + return []; + }, + listModels: async () => [ + { id: "claude-sonnet-5", name: "Claude Sonnet 5", capabilities: {}, billing: { multiplier: 1 } }, + { id: "gpt-6-sol", name: "GPT-6 Sol", capabilities: {}, policy: { state: "disabled", terms: "" } }, + { id: "auto", name: "Auto", capabilities: {} }, + ], + } as unknown as CopilotClient; + const engine = new CopilotEngine({ client: () => client }); + + const models = await engine.listModels(); + + expect(models).toEqual([ + { id: "claude-sonnet-5", name: "Claude Sonnet 5", multiplier: 1 }, + { id: "auto", name: "Auto" }, + ]); + expect(stopped).toBe(true); +}); + +test("edits in the loop's worktree are auto-accepted unless a person chose review", () => { + expect(defaultSettings.editsInWorktree).toBe("auto"); +}); diff --git a/NodRuntime/test/runtime.test.ts b/NodRuntime/test/runtime.test.ts index 8d66943b..1868d200 100644 --- a/NodRuntime/test/runtime.test.ts +++ b/NodRuntime/test/runtime.test.ts @@ -137,7 +137,7 @@ describe("NodRuntime", () => { test("an in-worktree edit is staged and held until reviewed; the tool runs only when all accepted", async () => { const engine = new FakeEngine(); - const { runtime, records, cwd, labels, presence } = setup(engine); + const { runtime, records, cwd, labels, presence } = setup(engine, { settings: { editsInWorktree: "reviewHunks" } }); const file = join(cwd, "Routes.swift"); writeFileSync(file, "a\nb\nc\n"); let authorization: unknown; @@ -257,7 +257,7 @@ describe("NodRuntime", () => { test("stop interrupts the turn, rejects pending hunks, denies open asks and clears the queue", async () => { const engine = new FakeEngine(); - const { runtime, records, cwd } = setup(engine, { goal: "it works" }); + const { runtime, records, cwd } = setup(engine, { goal: "it works", settings: { editsInWorktree: "reviewHunks" } }); const file = join(cwd, "A.swift"); writeFileSync(file, "x\n"); engine.queueTurn(async (cb) => { diff --git a/graphcode/Sources/Clients/NodModelDiscovery.swift b/graphcode/Sources/Clients/NodModelDiscovery.swift new file mode 100644 index 00000000..369028bc --- /dev/null +++ b/graphcode/Sources/Clients/NodModelDiscovery.swift @@ -0,0 +1,65 @@ +import Foundation +import GraphcodeKit + +/// Asks the Copilot SDK which models the signed-in account may use and merges them into +/// `NodModelCatalog`, so Nod offers models the built-in list predates. The last answer is +/// cached, so pickers have it from launch rather than after a network round trip. +enum NodModelDiscovery { + struct Listing: Decodable, Equatable { + struct Model: Decodable, Equatable { + var id: String + var name: String + } + + var models: [Model]? + } + + static var cacheFile: URL { + SupportDirectory.url.appendingPathComponent("nod/copilot-models.json") + } + + static func models(from data: Data) -> [NodModel] { + guard let listing = try? JSONDecoder().decode(Listing.self, from: data) else { return [] } + var seen = Set() + return (listing.models ?? []).compactMap { model in + guard seen.insert(model.id).inserted else { return nil } + return NodModelCatalog.copilotModel(id: model.id, name: model.name) + } + } + + static func loadCache() { + guard let data = try? Data(contentsOf: cacheFile) else { return } + NodModelCatalog.discoveredCopilotModels = models(from: data) + } + + /// Runs `graphcode-nod --list-models` in the background; a failure leaves the cache as is. + static func refresh() { + guard let binary = NodRuntimeLocator.binaryURL() else { return } + Task.detached(priority: .utility) { + let process = Process() + process.executableURL = binary + process.arguments = ["--list-models", "--engine", "copilot"] + let output = Pipe() + process.standardOutput = output + process.standardError = FileHandle.nullDevice + let data: Data? = await withCheckedContinuation { continuation in + process.terminationHandler = { process in + let data = output.fileHandleForReading.readDataToEndOfFile() + continuation.resume(returning: process.terminationStatus == 0 ? data : nil) + } + do { + try process.run() + } catch { + process.terminationHandler = nil + continuation.resume(returning: nil) + } + } + guard let data, !models(from: data).isEmpty else { return } + try? FileManager.default.createDirectory( + at: cacheFile.deletingLastPathComponent(), withIntermediateDirectories: true) + try? data.write(to: cacheFile, options: .atomic) + let found = models(from: data) + await MainActor.run { NodModelCatalog.discoveredCopilotModels = found } + } + } +} diff --git a/graphcode/Sources/Features/Nod/NodChatFeature.swift b/graphcode/Sources/Features/Nod/NodChatFeature.swift index d0706f6b..538fbd2a 100644 --- a/graphcode/Sources/Features/Nod/NodChatFeature.swift +++ b/graphcode/Sources/Features/Nod/NodChatFeature.swift @@ -18,7 +18,7 @@ struct NodChatFeature { var loopType: LoopType var branch: String? var goal: String? - var editPolicy: NodSettings.EditPolicy = .reviewHunks + var editPolicy: NodSettings.EditPolicy = .auto var transcript = NodTranscript() /// The model picked from the chip since the session started; the log only names the diff --git a/graphcode/Sources/GraphcodeApp.swift b/graphcode/Sources/GraphcodeApp.swift index a7f8a8f8..403eb10f 100644 --- a/graphcode/Sources/GraphcodeApp.swift +++ b/graphcode/Sources/GraphcodeApp.swift @@ -31,6 +31,8 @@ struct GraphcodeApp: App { if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil { FeatureRamps.publishNodFlag(enabled: FeatureRamps.isEnabled(.nod)) NodRuntimeLocator.installAvailability() + NodModelDiscovery.loadCache() + NodModelDiscovery.refresh() } let nodCards = NodLiveCardState() NodCardWiring.provider = nodCards diff --git a/graphcode/Tests/NodBackendTests.swift b/graphcode/Tests/NodBackendTests.swift index 099d731b..9bc000af 100644 --- a/graphcode/Tests/NodBackendTests.swift +++ b/graphcode/Tests/NodBackendTests.swift @@ -182,7 +182,7 @@ struct NodSettingsTests { let settings = try JSONDecoder().decode(GraphcodeSettings.self, from: Data("{}".utf8)) #expect(settings.nod == NodSettings()) - #expect(settings.nod.editsInWorktree == .reviewHunks) + #expect(settings.nod.editsInWorktree == .auto) #expect(settings.nod.editsOutsideWorktree == .never) #expect(settings.nod.messagesOtherLoops == .draftForMe) } diff --git a/graphcode/Tests/NodSetupTests.swift b/graphcode/Tests/NodSetupTests.swift index 366101a6..fce82af7 100644 --- a/graphcode/Tests/NodSetupTests.swift +++ b/graphcode/Tests/NodSetupTests.swift @@ -13,6 +13,42 @@ import Testing } } + /// The SDK's `listModels()` answers only "Auto" for some accounts, so the built-in list + /// is what the pickers show; it tracks `copilot help config`. + @Test func copilotOffersTheCLIsModelsAndKeepsItsDefaults() { + let ids = NodModelCatalog.models(for: .copilotSDK).map(\.id) + for id in ["claude-fable-5.1", "gpt-6-luna", "gpt-5-mini", "grok-4.5", "kimi-k3", "auto"] { + #expect(ids.contains(id)) + } + #expect(NodModelCatalog.model(for: .capable, engine: .copilotSDK).id == "claude-opus-5.5") + #expect(NodModelCatalog.model(for: .standard, engine: .copilotSDK).id == "gpt-6-sol") + #expect(NodModelCatalog.model(for: .fast, engine: .copilotSDK).id == "gpt-5.6-luna") + } + + @Test func copilotIDsReadAsNames() { + #expect(NodModelCatalog.copilotModel(id: "gpt-6-sol").displayName == "GPT-6 Sol") + #expect( + NodModelCatalog.copilotModel(id: "claude-opus-4.8-fast").displayName == "Claude Opus 4.8 Fast" + ) + #expect( + NodModelCatalog.copilotModel(id: "mai-code-1.1-flash").displayName == "MAI Code 1.1 Flash") + #expect(NodModelCatalog.copilotModel(id: "auto").displayName == "Auto") + } + + /// `graphcode-nod --list-models` repeats ids (the SDK listed "auto" twice); each model + /// appears once, named as the SDK names it. + @Test func aDiscoveredListIsReadOncePerModel() { + let json = + #"{"engine":"copilot","models":[{"id":"auto","name":"Auto"},{"id":"auto","name":"Auto"},{"id":"o-next","name":"O Next"}]}"# + + let models = NodModelDiscovery.models(from: Data(json.utf8)) + + #expect(models.map(\.id) == ["auto", "o-next"]) + #expect(models.last?.displayName == "O Next") + #expect( + NodModelDiscovery.models(from: Data(#"{"engine":"claude","models":null}"#.utf8)).isEmpty) + } + @Test func defaultsFollowTheDesignPerLoopType() { let settings = NodSettings() #expect(settings.resolvedModel(for: .sketch).displayName == "Sonnet")