From 80ae29dbf24ed2a35f4f60600f99d00a86966239 Mon Sep 17 00:00:00 2001 From: Skyler Liu <9743878+JayDaniel@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:49:10 -0700 Subject: [PATCH 01/16] Support the Xcode 26 toolchain TCA 1.16.1 does not compile under Swift 6.3, so moving to the new toolchain forces a matching dependency bump. - Upgrade swift-composable-architecture from 1.16.1 to 1.26.2 and adopt the new Shared(value:) signature - Align the Tool and ChatPlugins package platforms to macOS 13, so they match every target in the project - Declare the missing CommandHandler dependency in SuggestionWidget, which was breaking the test bundle link step - Drop stale entries from Package.resolved Claude-Session: https://claude.ai/code/session_01QrUJLx9ppwpa2DFUoSGUYo --- ChatPlugins/Package.swift | 2 +- .../xcshareddata/swiftpm/Package.resolved | 122 +++--------------- Core/Package.swift | 3 +- .../WindowBaseCommandHandler.swift | 2 +- .../PromptToCodePanelView.swift | 6 +- Tool/Package.swift | 4 +- 6 files changed, 25 insertions(+), 114 deletions(-) diff --git a/ChatPlugins/Package.swift b/ChatPlugins/Package.swift index 4defd772..f4f2ebe7 100644 --- a/ChatPlugins/Package.swift +++ b/ChatPlugins/Package.swift @@ -5,7 +5,7 @@ import PackageDescription let package = Package( name: "ChatPlugins", - platforms: [.macOS(.v12)], + platforms: [.macOS(.v13)], products: [ .library( name: "ChatPlugins", diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved index 87fd4d4e..0c3d7e41 100644 --- a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,14 +1,5 @@ { "pins" : [ - { - "identity" : "aexml", - "kind" : "remoteSourceControl", - "location" : "https://github.com/tadija/AEXML.git", - "state" : { - "revision" : "db806756c989760b35108146381535aec231092b", - "version" : "4.7.0" - } - }, { "identity" : "cgeventoverride", "kind" : "remoteSourceControl", @@ -81,15 +72,6 @@ "revision" : "81d8c8b3733939bf5d9e52cd6318f944cc033bd2" } }, - { - "identity" : "indexstore-db", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/indexstore-db.git", - "state" : { - "branch" : "release/6.1", - "revision" : "54212fce1aecb199070808bdb265e7f17e396015" - } - }, { "identity" : "jsonrpc", "kind" : "remoteSourceControl", @@ -126,15 +108,6 @@ "version" : "0.8.0" } }, - { - "identity" : "messagepacker", - "kind" : "remoteSourceControl", - "location" : "https://github.com/hirotakan/MessagePacker.git", - "state" : { - "revision" : "4d8346c6bc579347e4df0429493760691c5aeca2", - "version" : "0.4.7" - } - }, { "identity" : "networkimage", "kind" : "remoteSourceControl", @@ -153,15 +126,6 @@ "version" : "1.6.0" } }, - { - "identity" : "pathkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/kylef/PathKit.git", - "state" : { - "revision" : "3bfd2737b700b9a36565a8c94f4ad2b050a5e574", - "version" : "1.0.1" - } - }, { "identity" : "processenv", "kind" : "remoteSourceControl", @@ -171,15 +135,6 @@ "version" : "0.3.1" } }, - { - "identity" : "sourcekitten", - "kind" : "remoteSourceControl", - "location" : "https://github.com/jpsim/SourceKitten", - "state" : { - "revision" : "eb6656ed26bdef967ad8d07c27e2eab34dc582f2", - "version" : "0.37.0" - } - }, { "identity" : "sparkle", "kind" : "remoteSourceControl", @@ -189,15 +144,6 @@ "version" : "2.7.0" } }, - { - "identity" : "spectre", - "kind" : "remoteSourceControl", - "location" : "https://github.com/kylef/Spectre.git", - "state" : { - "revision" : "26cc5e9ae0947092c7139ef7ba612e34646086c7", - "version" : "0.10.1" - } - }, { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", @@ -257,8 +203,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-composable-architecture", "state" : { - "revision" : "69247baf7be2fd6f5820192caef0082d01849cd0", - "version" : "1.16.1" + "revision" : "377da4061db10d26337a71bb279c506bb951f50f", + "version" : "1.26.2" } }, { @@ -311,8 +257,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-navigation", "state" : { - "revision" : "db6bc9dbfed001f21e6728fd36413d9342c235b4", - "version" : "2.3.0" + "revision" : "32f35241b8be0719c4c7f00eb27713b1cadb6248", + "version" : "2.8.0" } }, { @@ -333,10 +279,19 @@ "version" : "1.6.0" } }, + { + "identity" : "swift-sharing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-sharing", + "state" : { + "revision" : "3552faf8a6c18ce896ec5a72bd8c34755f0f03e0", + "version" : "2.10.1" + } + }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-syntax.git", + "location" : "https://github.com/swiftlang/swift-syntax", "state" : { "revision" : "0687f71944021d616d34d922343dcef086855920", "version" : "600.0.1" @@ -351,15 +306,6 @@ "version" : "2.6.1" } }, - { - "identity" : "swiftterm", - "kind" : "remoteSourceControl", - "location" : "https://github.com/migueldeicaza/SwiftTerm", - "state" : { - "revision" : "e2b431dbf73f775fb4807a33e4572ffd3dc6933a", - "version" : "1.2.5" - } - }, { "identity" : "swifttreesitter", "kind" : "remoteSourceControl", @@ -369,24 +315,6 @@ "revision" : "fd499bfafcccfae12a1a579dc922d8418025a35d" } }, - { - "identity" : "swiftui-introspect", - "kind" : "remoteSourceControl", - "location" : "https://github.com/siteline/swiftui-introspect", - "state" : { - "revision" : "807f73ce09a9b9723f12385e592b4e0aaebd3336", - "version" : "1.3.0" - } - }, - { - "identity" : "swxmlhash", - "kind" : "remoteSourceControl", - "location" : "https://github.com/drmohundro/SWXMLHash.git", - "state" : { - "revision" : "a853604c9e9a83ad9954c7e3d2a565273982471f", - "version" : "7.0.2" - } - }, { "identity" : "tiktoken", "kind" : "remoteSourceControl", @@ -414,31 +342,13 @@ "version" : "0.19.3" } }, - { - "identity" : "xcodeproj", - "kind" : "remoteSourceControl", - "location" : "https://github.com/tuist/XcodeProj.git", - "state" : { - "revision" : "b1caa062d4aaab3e3d2bed5fe0ac5f8ce9bf84f4", - "version" : "8.27.7" - } - }, { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4", - "version" : "1.5.2" - } - }, - { - "identity" : "yams", - "kind" : "remoteSourceControl", - "location" : "https://github.com/jpsim/Yams.git", - "state" : { - "revision" : "b4b8042411dc7bbb696300a34a4bf3ba1b7ad19b", - "version" : "5.3.1" + "revision" : "d9308ad679143c26a30dec52daefe82f54af2b82", + "version" : "1.13.1" } } ], diff --git a/Core/Package.swift b/Core/Package.swift index 6cd0910a..6e910c1f 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -46,7 +46,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.0.0"), .package( url: "https://github.com/pointfreeco/swift-composable-architecture", - exact: "1.16.1" + exact: "1.26.2" ), // quick hack to support custom UserDefaults // https://github.com/sindresorhus/KeyboardShortcuts @@ -233,6 +233,7 @@ let package = Package( .product(name: "Logger", package: "Tool"), .product(name: "CustomAsyncAlgorithms", package: "Tool"), .product(name: "CodeDiff", package: "Tool"), + .product(name: "CommandHandler", package: "Tool"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), .product(name: "MarkdownUI", package: "swift-markdown-ui"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift index 53b0c833..884eeae8 100644 --- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift +++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift @@ -471,7 +471,7 @@ extension WindowBaseCommandHandler { _ = await MainActor.run { store.send(.promptToCodeGroup(.activateOrCreatePromptToCode(.init( - promptToCodeState: Shared(.init( + promptToCodeState: Shared(value: .init( source: .init( language: codeLanguage, documentURL: fileURL, diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift index ef3b560c..4a9849ec 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanelView.swift @@ -1082,7 +1082,7 @@ extension PromptToCodePanelView { #Preview("Multiple Snippets") { PromptToCodePanelView(store: .init(initialState: .init( - promptToCodeState: Shared(ModificationState( + promptToCodeState: Shared(value: ModificationState( source: .init( language: CodeLanguage.builtIn(.swift), documentURL: URL( @@ -1164,7 +1164,7 @@ extension PromptToCodePanelView { #Preview("Detached With Long File Name") { PromptToCodePanelView(store: .init(initialState: .init( - promptToCodeState: Shared(ModificationState( + promptToCodeState: Shared(value: ModificationState( source: .init( language: CodeLanguage.builtIn(.swift), documentURL: URL( @@ -1219,7 +1219,7 @@ extension PromptToCodePanelView { #Preview("Generating") { PromptToCodePanelView(store: .init(initialState: .init( - promptToCodeState: Shared(ModificationState( + promptToCodeState: Shared(value: ModificationState( source: .init( language: CodeLanguage.builtIn(.swift), documentURL: URL( diff --git a/Tool/Package.swift b/Tool/Package.swift index f303e44c..b2bc6cb1 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -5,7 +5,7 @@ import PackageDescription let package = Package( name: "Tool", - platforms: [.macOS(.v12)], + platforms: [.macOS(.v13)], products: [ .library(name: "XPCShared", targets: ["XPCShared"]), .library(name: "Terminal", targets: ["Terminal"]), @@ -73,7 +73,7 @@ let package = Package( .package(url: "https://github.com/intitni/Highlightr", branch: "master"), .package( url: "https://github.com/pointfreeco/swift-composable-architecture", - exact: "1.16.1" + exact: "1.26.2" ), .package(url: "https://github.com/apple/swift-syntax.git", from: "600.0.0"), .package(url: "https://github.com/GottaGetSwifty/CodableWrappers", from: "2.0.7"), From 3c35067e3c73203ee720804385747844bb6d5015 Mon Sep 17 00:00:00 2001 From: Skyler Liu <9743878+JayDaniel@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:50:55 -0700 Subject: [PATCH 02/16] Add a built-in custom model suggestion provider The open source build hosts no third-party ExtensionKit extension, so CustomSuggestionServiceForCopilotForXcode cannot run as a separate app. Merge it into the Tool package and expose it as a built-in extension instead. - Add the CustomSuggestionService module to the Tool package - Support seven API shapes: OpenAI compatible, Azure, Claude, Gemini, Ollama, Mistral FIM and Tabby - Add the CompletionModel, FIMModel and TabbyModel types along with the customSuggestion* preference keys - Register through BuiltinExtension, so that Custom Model appears as a suggestion provider - Port the 31 unit tests from the original project Claude-Session: https://claude.ai/code/session_01QrUJLx9ppwpa2DFUoSGUYo --- Core/Package.swift | 2 + ...SuggestionSettingsGeneralSectionView.swift | 3 + Core/Sources/Service/Service.swift | 2 + .../SuggestionService/SuggestionService.swift | 6 + TestPlan.xctestplan | 7 + Tool/Package.swift | 22 + Tool/Sources/AIModel/CompletionModel.swift | 102 ++++ Tool/Sources/AIModel/CustomModelType.swift | 11 + Tool/Sources/AIModel/FIMModel.swift | 105 ++++ Tool/Sources/AIModel/TabbyModel.swift | 41 ++ .../BuiltinExtensionManager.swift | 2 + .../API/AnthropicService.swift | 235 +++++++++ .../API/AzureOpenAIService.swift | 197 ++++++++ .../API/GoogleGeminiService.swift | 127 +++++ .../API/MistralFIMService.swift | 148 ++++++ .../API/OllamaService.swift | 319 ++++++++++++ .../API/OpenAIService.swift | 476 ++++++++++++++++++ .../API/TabbyService.swift | 138 +++++ .../CodeCompletionLogger.swift | 139 +++++ .../CodeCompletionService.swift | 348 +++++++++++++ .../CustomModelExtension.swift | 22 + .../CustomModelSuggestionService.swift | 33 ++ .../CustomSuggestionCoordinator.swift | 227 +++++++++ .../PromptStrategy.swift | 100 ++++ ...tRawSuggestionPostProcessingStrategy.swift | 160 ++++++ ...pRawSuggestionPostProcessingStrategy.swift | 8 + .../AnthropicRequestStrategy.swift | 161 ++++++ .../ContinueRequestStrategy.swift | 176 +++++++ .../DefaultRequestStrategy.swift | 162 ++++++ .../FIMEndpointRequestStrategy.swift | 80 +++ .../FillInTheMiddleRequestStrategy.swift | 150 ++++++ .../NaiveRequestStrategy.swift | 86 ++++ .../TabbyRequestStrategy.swift | 66 +++ .../RequestStrategy.swift | 83 +++ .../ResponseStream.swift | 45 ++ .../StreamLineLimiter.swift | 63 +++ .../DefaultStreamStopStrategy.swift | 15 + .../NeverStreamStopStrategy.swift | 12 + .../OpeningTagBasedStreamStopStrategy.swift | 31 ++ .../StreamStopStrategy.swift | 12 + .../TextProcessing/ConvertRange.swift | 34 ++ .../TextProcessing/String+Extensions.swift | 52 ++ .../TruncateStrategy/TruncateStrategy.swift | 91 ++++ .../CustomSuggestionServiceKeys.swift | 85 ++++ .../Types/SuggestionFeatureProvider.swift | 1 + .../CodeSplitAtCursorTests.swift | 100 ++++ .../ConvertRangeTests.swift | 42 ++ ...uggestionPostProcessingStrategyTests.swift | 225 +++++++++ .../DefaultRequestStrategyTests.swift | 126 +++++ ...eningTagBasedStreamStopStrategyTests.swift | 106 ++++ .../StreamLineLimiterTests.swift | 69 +++ 51 files changed, 5053 insertions(+) create mode 100644 Tool/Sources/AIModel/CompletionModel.swift create mode 100644 Tool/Sources/AIModel/CustomModelType.swift create mode 100644 Tool/Sources/AIModel/FIMModel.swift create mode 100644 Tool/Sources/AIModel/TabbyModel.swift create mode 100644 Tool/Sources/CustomSuggestionService/API/AnthropicService.swift create mode 100644 Tool/Sources/CustomSuggestionService/API/AzureOpenAIService.swift create mode 100644 Tool/Sources/CustomSuggestionService/API/GoogleGeminiService.swift create mode 100644 Tool/Sources/CustomSuggestionService/API/MistralFIMService.swift create mode 100644 Tool/Sources/CustomSuggestionService/API/OllamaService.swift create mode 100644 Tool/Sources/CustomSuggestionService/API/OpenAIService.swift create mode 100644 Tool/Sources/CustomSuggestionService/API/TabbyService.swift create mode 100644 Tool/Sources/CustomSuggestionService/CodeCompletionLogger.swift create mode 100644 Tool/Sources/CustomSuggestionService/CodeCompletionService.swift create mode 100644 Tool/Sources/CustomSuggestionService/CustomModelExtension.swift create mode 100644 Tool/Sources/CustomSuggestionService/CustomModelSuggestionService.swift create mode 100644 Tool/Sources/CustomSuggestionService/CustomSuggestionCoordinator.swift create mode 100644 Tool/Sources/CustomSuggestionService/PromptStrategy.swift create mode 100644 Tool/Sources/CustomSuggestionService/RawSuggestionPostProcessing/DefaultRawSuggestionPostProcessingStrategy.swift create mode 100644 Tool/Sources/CustomSuggestionService/RawSuggestionPostProcessing/NoOpRawSuggestionPostProcessingStrategy.swift create mode 100644 Tool/Sources/CustomSuggestionService/RequestStrategies/AnthropicRequestStrategy.swift create mode 100644 Tool/Sources/CustomSuggestionService/RequestStrategies/ContinueRequestStrategy.swift create mode 100644 Tool/Sources/CustomSuggestionService/RequestStrategies/DefaultRequestStrategy.swift create mode 100644 Tool/Sources/CustomSuggestionService/RequestStrategies/FIMEndpointRequestStrategy.swift create mode 100644 Tool/Sources/CustomSuggestionService/RequestStrategies/FillInTheMiddleRequestStrategy.swift create mode 100644 Tool/Sources/CustomSuggestionService/RequestStrategies/NaiveRequestStrategy.swift create mode 100644 Tool/Sources/CustomSuggestionService/RequestStrategies/TabbyRequestStrategy.swift create mode 100644 Tool/Sources/CustomSuggestionService/RequestStrategy.swift create mode 100644 Tool/Sources/CustomSuggestionService/ResponseStream.swift create mode 100644 Tool/Sources/CustomSuggestionService/StreamLineLimiter.swift create mode 100644 Tool/Sources/CustomSuggestionService/StreamStopStrategy/DefaultStreamStopStrategy.swift create mode 100644 Tool/Sources/CustomSuggestionService/StreamStopStrategy/NeverStreamStopStrategy.swift create mode 100644 Tool/Sources/CustomSuggestionService/StreamStopStrategy/OpeningTagBasedStreamStopStrategy.swift create mode 100644 Tool/Sources/CustomSuggestionService/StreamStopStrategy/StreamStopStrategy.swift create mode 100644 Tool/Sources/CustomSuggestionService/TextProcessing/ConvertRange.swift create mode 100644 Tool/Sources/CustomSuggestionService/TextProcessing/String+Extensions.swift create mode 100644 Tool/Sources/CustomSuggestionService/TruncateStrategy/TruncateStrategy.swift create mode 100644 Tool/Sources/Preferences/CustomSuggestionServiceKeys.swift create mode 100644 Tool/Tests/CustomSuggestionServiceTests/CodeSplitAtCursorTests.swift create mode 100644 Tool/Tests/CustomSuggestionServiceTests/ConvertRangeTests.swift create mode 100644 Tool/Tests/CustomSuggestionServiceTests/DefaultRawSuggestionPostProcessingStrategyTests.swift create mode 100644 Tool/Tests/CustomSuggestionServiceTests/DefaultRequestStrategyTests.swift create mode 100644 Tool/Tests/CustomSuggestionServiceTests/OpeningTagBasedStreamStopStrategyTests.swift create mode 100644 Tool/Tests/CustomSuggestionServiceTests/StreamLineLimiterTests.swift diff --git a/Core/Package.swift b/Core/Package.swift index 6e910c1f..fb1fdcb9 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -72,6 +72,7 @@ let package = Package( .target( name: "Service", dependencies: [ + .product(name: "CustomSuggestionService", package: "Tool"), "SuggestionWidget", "SuggestionService", "ChatService", @@ -144,6 +145,7 @@ let package = Package( .target( name: "SuggestionService", dependencies: [ + .product(name: "CustomSuggestionService", package: "Tool"), .product(name: "UserDefaultsObserver", package: "Tool"), .product(name: "Preferences", package: "Tool"), .product(name: "SuggestionBasic", package: "Tool"), diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift index 390c7f98..21cac492 100644 --- a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift +++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsGeneralSectionView.swift @@ -143,6 +143,9 @@ struct SuggestionSettingsGeneralSectionView: View { case .codeium: Text("Codeium") .tag(SuggestionFeatureProviderOption(name: "", builtInProvider: $0)) + case .customModel: + Text("Custom Model") + .tag(SuggestionFeatureProviderOption(name: "", builtInProvider: $0)) } } diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift index b1702924..8fbcbdf3 100644 --- a/Core/Sources/Service/Service.swift +++ b/Core/Sources/Service/Service.swift @@ -1,5 +1,6 @@ import BuiltinExtension import CodeiumService +import CustomSuggestionService import Combine import CommandHandler import Dependencies @@ -65,6 +66,7 @@ public final class Service { BuiltinExtensionManager.shared.addExtensions([ GitHubCopilotExtension(workspacePool: workspacePool), CodeiumExtension(workspacePool: workspacePool), + CustomModelExtension(), ]) let guiController = GraphicalUserInterfaceController() diff --git a/Core/Sources/SuggestionService/SuggestionService.swift b/Core/Sources/SuggestionService/SuggestionService.swift index 335f0c83..c898f792 100644 --- a/Core/Sources/SuggestionService/SuggestionService.swift +++ b/Core/Sources/SuggestionService/SuggestionService.swift @@ -1,5 +1,6 @@ import BuiltinExtension import CodeiumService +import CustomSuggestionService import enum CopilotForXcodeKit.SuggestionServiceError import struct CopilotForXcodeKit.WorkspaceInfo import Foundation @@ -54,6 +55,11 @@ public actor SuggestionService: SuggestionServiceType { extension: CodeiumExtension.self ) return SuggestionService(provider: provider) + case .builtIn(.customModel): + let provider = BuiltinExtensionSuggestionServiceProvider( + extension: CustomModelExtension.self + ) + return SuggestionService(provider: provider) case .builtIn(.gitHubCopilot), .extension: let provider = BuiltinExtensionSuggestionServiceProvider( extension: GitHubCopilotExtension.self diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan index c9ebe525..11113895 100644 --- a/TestPlan.xctestplan +++ b/TestPlan.xctestplan @@ -182,6 +182,13 @@ "identifier" : "TokenEncoderTests", "name" : "TokenEncoderTests" } + }, + { + "target" : { + "containerPath" : "container:Tool", + "identifier" : "CustomSuggestionServiceTests", + "name" : "CustomSuggestionServiceTests" + } } ], "version" : 1 diff --git a/Tool/Package.swift b/Tool/Package.swift index b2bc6cb1..57ccc516 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -52,6 +52,7 @@ let package = Package( .library(name: "CommandHandler", targets: ["CommandHandler"]), .library(name: "CodeDiff", targets: ["CodeDiff"]), .library(name: "BuiltinExtension", targets: ["BuiltinExtension"]), + .library(name: "CustomSuggestionService", targets: ["CustomSuggestionService"]), .library(name: "WebSearchService", targets: ["WebSearchService"]), .library(name: "WebScrapper", targets: ["WebScrapper"]), .library( @@ -460,6 +461,27 @@ let package = Package( ] ), + // MARK: - Custom Model Suggestion + + .target( + name: "CustomSuggestionService", + dependencies: [ + "AIModel", + "Preferences", + "Keychain", + "Logger", + "BuiltinExtension", + .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"), + .product(name: "GoogleGenerativeAI", package: "generative-ai-swift"), + .product(name: "Parsing", package: "swift-parsing"), + .product(name: "CodableWrappers", package: "CodableWrappers"), + ] + ), + .testTarget( + name: "CustomSuggestionServiceTests", + dependencies: ["CustomSuggestionService", "AIModel"] + ), + // MARK: - OpenAI .target( diff --git a/Tool/Sources/AIModel/CompletionModel.swift b/Tool/Sources/AIModel/CompletionModel.swift new file mode 100644 index 00000000..3ba7ed95 --- /dev/null +++ b/Tool/Sources/AIModel/CompletionModel.swift @@ -0,0 +1,102 @@ +import CodableWrappers +import Foundation + +/// A completion model. +public struct CompletionModel: Codable, Equatable, Identifiable { + public var id: String + public var name: String + @FallbackDecoding + public var format: Format + @FallbackDecoding + public var info: Info + + public init(id: String, name: String, format: Format, info: Info) { + self.id = id + self.name = name + self.format = format + self.info = info + } + + public enum Format: String, Codable, Equatable, CaseIterable { + case openAI + case azureOpenAI + case openAICompatible + case ollama + + case unknown + } + + public struct Info: Codable, Equatable { + public typealias OllamaInfo = ChatModel.Info.OllamaInfo + public typealias OpenAIInfo = ChatModel.Info.OpenAIInfo + + @FallbackDecoding + public var apiKeyName: String + @FallbackDecoding + public var baseURL: String + @FallbackDecoding + public var isFullURL: Bool + @FallbackDecoding + public var maxTokens: Int + @FallbackDecoding + public var modelName: String + + @FallbackDecoding + public var openAIInfo: OpenAIInfo + @FallbackDecoding + public var ollamaInfo: OllamaInfo + + public init( + apiKeyName: String = "", + baseURL: String = "", + isFullURL: Bool = false, + maxTokens: Int = 4000, + modelName: String = "", + openAIInfo: OpenAIInfo = OpenAIInfo(), + ollamaInfo: OllamaInfo = OllamaInfo() + ) { + self.apiKeyName = apiKeyName + self.baseURL = baseURL + self.isFullURL = isFullURL + self.maxTokens = maxTokens + self.modelName = modelName + self.openAIInfo = openAIInfo + self.ollamaInfo = ollamaInfo + } + } + + public var endpoint: String { + switch format { + case .openAI: + let baseURL = info.baseURL + if baseURL.isEmpty { return "https://api.openai.com/v1/completions" } + return "\(baseURL)/v1/completions" + case .openAICompatible: + let baseURL = info.baseURL + if baseURL.isEmpty { return "https://api.openai.com/v1/completions" } + if info.isFullURL { return baseURL } + return "\(baseURL)/v1/completions" + case .azureOpenAI: + let baseURL = info.baseURL + let deployment = info.modelName + let version = "2023-07-01-preview" + if baseURL.isEmpty { return "" } + return "\(baseURL)/openai/deployments/\(deployment)/completions?api-version=\(version)" + case .ollama: + let baseURL = info.baseURL + if baseURL.isEmpty { return "http://localhost:11434/api/generate" } + return "\(baseURL)/api/generate" + case .unknown: + return "" + } + } +} + +public struct EmptyCompletionModelInfo: FallbackValueProvider { + public static var defaultValue: CompletionModel.Info { .init() } +} + +public struct EmptyCompletionModelFormat: FallbackValueProvider { + public static var defaultValue: CompletionModel.Format { .unknown } +} + diff --git a/Tool/Sources/AIModel/CustomModelType.swift b/Tool/Sources/AIModel/CustomModelType.swift new file mode 100644 index 00000000..82091fef --- /dev/null +++ b/Tool/Sources/AIModel/CustomModelType.swift @@ -0,0 +1,11 @@ +import Foundation + +public enum CustomModelType: String, CaseIterable { + case completionModel + case fimModel + case tabby + + public static var `default`: CustomModelType { + .completionModel + } +} diff --git a/Tool/Sources/AIModel/FIMModel.swift b/Tool/Sources/AIModel/FIMModel.swift new file mode 100644 index 00000000..7d1aaac4 --- /dev/null +++ b/Tool/Sources/AIModel/FIMModel.swift @@ -0,0 +1,105 @@ +import CodableWrappers +import Foundation + +/// A completion model. +public struct FIMModel: Codable, Equatable, Identifiable { + public var id: String + public var name: String + @FallbackDecoding + public var format: Format + @FallbackDecoding + public var info: Info + + public init(id: String, name: String, format: Format, info: Info) { + self.id = id + self.name = name + self.format = format + self.info = info + } + + public enum Format: String, Codable, Equatable, CaseIterable { + case mistral + case ollama + case ollamaCompatible + + case unknown + } + + public struct Info: Codable, Equatable { + @FallbackDecoding + public var apiKeyName: String + @FallbackDecoding + public var baseURL: String + @FallbackDecoding + public var isFullURL: Bool + @FallbackDecoding + public var maxTokens: Int + @FallbackDecoding + public var modelName: String + @FallbackDecoding + public var authenticationMode: AuthenticationMode + @FallbackDecoding + public var authenticationHeaderFieldName: String + + public enum AuthenticationMode: Codable, Equatable, CaseIterable { + case header + case bearerToken + } + + @FallbackDecoding + public var ollamaInfo: ChatModel.Info.OllamaInfo + + public init( + apiKeyName: String = "", + baseURL: String = "", + isFullURL: Bool = false, + maxTokens: Int = 4000, + modelName: String = "", + authenticationMode: AuthenticationMode = .bearerToken, + authenticationHeaderFieldName: String = "", + ollamaInfo: ChatModel.Info.OllamaInfo = ChatModel.Info.OllamaInfo() + ) { + self.apiKeyName = apiKeyName + self.baseURL = baseURL + self.isFullURL = isFullURL + self.maxTokens = maxTokens + self.modelName = modelName + self.ollamaInfo = ollamaInfo + self.authenticationMode = authenticationMode + self.authenticationHeaderFieldName = authenticationHeaderFieldName + } + } + + public var endpoint: String { + switch format { + case .mistral: + let baseURL = info.baseURL + if baseURL.isEmpty { return "https://api.mistral.ai/v1/fim/completions" } + if info.isFullURL { return baseURL } + return "\(baseURL)/v1/fim/completions" + case .ollama: + let baseURL = info.baseURL + if baseURL.isEmpty { return "http://localhost:11434/api/generate" } + return "\(baseURL)/api/generate" + case .ollamaCompatible: + let baseURL = info.baseURL + if baseURL.isEmpty { return "http://localhost:11434/api/generate" } + if info.isFullURL { return baseURL } + return "\(baseURL)/api/generate" + case .unknown: + return "" + } + } +} + +public struct EmptyFIMModelInfo: FallbackValueProvider { + public static var defaultValue: FIMModel.Info { .init() } +} + +public struct EmptyFIMModelFormat: FallbackValueProvider { + public static var defaultValue: FIMModel.Format { .unknown } +} + +public struct EmptyFIMModelAuthenticationMode: FallbackValueProvider { + public static var defaultValue: FIMModel.Info.AuthenticationMode { .bearerToken } +} diff --git a/Tool/Sources/AIModel/TabbyModel.swift b/Tool/Sources/AIModel/TabbyModel.swift new file mode 100644 index 00000000..75af101d --- /dev/null +++ b/Tool/Sources/AIModel/TabbyModel.swift @@ -0,0 +1,41 @@ +import CodableWrappers +import Foundation + +public struct TabbyModel: Codable, Equatable { + public enum AuthorizationMode: String, Codable, CaseIterable { + case none + case bearerToken + case basic + case customHeaderField + } + + @FallbackDecoding + public var url: String + @FallbackDecoding + public var authorizationMode: AuthorizationMode + @FallbackDecoding + public var apiKeyName: String + @FallbackDecoding + public var authorizationHeaderName: String + @FallbackDecoding + public var username: String + + public init( + url: String, + authorizationMode: AuthorizationMode, + apiKeyName: String, + authorizationHeaderName: String, + username: String + ) { + self.url = url + self.authorizationMode = authorizationMode + self.apiKeyName = apiKeyName + self.authorizationHeaderName = authorizationHeaderName + self.username = username + } +} + +public struct EmptyAuthorizationMode: FallbackValueProvider { + public static var defaultValue: TabbyModel.AuthorizationMode { .none } +} + diff --git a/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift b/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift index 86832df5..3ebadf59 100644 --- a/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift +++ b/Tool/Sources/BuiltinExtension/BuiltinExtensionManager.swift @@ -50,6 +50,8 @@ extension BuiltinExtensionManager { ext.extensionIdentifier == "com.github.copilot" case .codeium: ext.extensionIdentifier == "com.codeium" + case .customModel: + ext.extensionIdentifier == "com.intii.CopilotForXcode.CustomModel" } case let .extension(_, bundleIdentifier): ext.extensionIdentifier == bundleIdentifier diff --git a/Tool/Sources/CustomSuggestionService/API/AnthropicService.swift b/Tool/Sources/CustomSuggestionService/API/AnthropicService.swift new file mode 100644 index 00000000..86360a72 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/API/AnthropicService.swift @@ -0,0 +1,235 @@ +import CopilotForXcodeKit +import Foundation +import AIModel + +public actor AnthropicService { + let url: URL + let modelName: String + let contextWindow: Int + let maxToken: Int + let temperature: Double + let apiKey: String + let stopWords: [String] + + init( + url: String? = nil, + modelName: String, + contextWindow: Int, + maxToken: Int, + temperature: Double = 0.2, + stopWords: [String] = [], + apiKey: String + ) { + self.url = url.flatMap(URL.init(string:)) ?? + URL(string: "https://api.anthropic.com/v1/messages")! + self.modelName = modelName + self.maxToken = maxToken + self.temperature = temperature + self.apiKey = apiKey + self.stopWords = stopWords + self.contextWindow = contextWindow + } + + public enum Models: String, CaseIterable { + case claude3Opus = "claude-3-opus-latest" + case claude35Sonnet = "claude-3-5-sonnet-latest" + case claude35Haiku = "claude-3-5-haiku-latest" + + public var maxToken: Int { + switch self { + case .claude3Opus: return 200_000 + case .claude35Sonnet: return 200_000 + case .claude35Haiku: return 200_000 + } + } + } +} + +// MARK: - CodeCompletionServiceType Implementation + +extension AnthropicService: CodeCompletionServiceType { + func getCompletion(_ request: PromptStrategy) async throws -> AsyncStream { + let (messages, systemPrompt) = createMessages(from: request) + CodeCompletionLogger.logger.logPrompt(messages.map { + ($0.content, $0.role.rawValue) + }) + let result = try await sendMessages(messages, systemPrompt: systemPrompt) + return result.compactMap { $0.delta?.text }.eraseToStream() + } +} + +// MARK: - Message Structure and Request Handling + +extension AnthropicService { + public struct Message: Codable { + public enum Role: String, Codable { + case user + case assistant + } + + var role: Role + var content: String + } + + struct MessageRequestBody: Codable { + var model: String + var messages: [Message] + var system: String? + var max_tokens: Int + var temperature: Double + var stream: Bool = true + var stop_sequences: [String]? + + enum CodingKeys: String, CodingKey { + case model + case messages + case system + case max_tokens + case temperature + case stream + case stop_sequences + } + } + + func createMessages(from request: PromptStrategy) -> (messages: [Message], system: String?) { + let strategy = DefaultTruncateStrategy(maxTokenLimit: max( + contextWindow / 3 * 2, + contextWindow - maxToken - 20 + )) + let prompts = strategy.createTruncatedPrompt(promptStrategy: request) + + let systemPrompt = request.systemPrompt + + let messages = prompts.map { prompt in + Message( + role: prompt.role == .user ? .user : .assistant, + content: prompt.content + ) + } + + return (messages: messages, system: systemPrompt) + } + + func sendMessages(_ messages: [Message], systemPrompt: String?) async throws -> ResponseStream { + let validStopSequences = stopWords.filter { + !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + let requestBody = MessageRequestBody( + model: modelName, + messages: messages, + system: systemPrompt, + max_tokens: maxToken, + temperature: temperature, + stop_sequences: validStopSequences + ) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.setValue("\(apiKey)", forHTTPHeaderField: "x-api-key") + request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + + let encoder = JSONEncoder() + request.httpBody = try encoder.encode(requestBody) + + let (result, response) = try await URLSession.shared.bytes(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw CancellationError() + } + + guard httpResponse.statusCode == 200 else { + let text = try await result.lines.reduce(into: "") { partialResult, current in + partialResult += current + } + throw Error.otherError(text) + } + + return ResponseStream(result: result) { + var text = $0 + + if text.hasPrefix("event: ") { + return .init(chunk: StreamResponse(), done: false) + } + + if text.hasPrefix("data: ") { + text = String(text.dropFirst(6)) + + guard !text.trimmingCharacters(in: .whitespaces).isEmpty else { + return .init(chunk: StreamResponse(), done: false) + } + + do { + let chunk = try JSONDecoder().decode( + StreamResponse.self, + from: text.data(using: .utf8) ?? Data() + ) + return .init( + chunk: chunk, + done: chunk.type == "message_stop" + ) + } catch { + print("Error decoding chunk: \(error)") + throw error + } + } + + return .init(chunk: StreamResponse(), done: false) + } + } +} + +// MARK: - API Response Structures + +extension AnthropicService { + struct StreamResponse: Decodable { + var type: String? + var delta: Delta? + var index: Int? + var content: [Content]? + + struct Delta: Decodable { + var text: String? + var type: String? + } + + struct Content: Decodable { + var text: String + var type: String + } + } + + struct APIError: Decodable { + var type: String + var message: String + var code: String? + } + + enum Error: Swift.Error, LocalizedError { + case decodeError(Swift.Error) + case apiError(APIError) + case otherError(String) + + var errorDescription: String? { + switch self { + case let .decodeError(error): + return error.localizedDescription + case let .apiError(error): + return error.message + case let .otherError(message): + return message + } + } + } +} + +// MARK: - Helper Methods + +extension AnthropicService { + func validateResponse(_ response: HTTPURLResponse) throws { + guard (200 ... 299).contains(response.statusCode) else { + throw Error.otherError("HTTP Error: \(response.statusCode)") + } + } +} diff --git a/Tool/Sources/CustomSuggestionService/API/AzureOpenAIService.swift b/Tool/Sources/CustomSuggestionService/API/AzureOpenAIService.swift new file mode 100644 index 00000000..a767275a --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/API/AzureOpenAIService.swift @@ -0,0 +1,197 @@ +import CopilotForXcodeKit +import Foundation +import AIModel + +public actor AzureOpenAIService { + let url: URL + let endpoint: OpenAIService.Endpoint + let modelName: String + let contextWindow: Int + let maxToken: Int + let temperature: Double + let apiKey: String + let stopWords: [String] + + init( + url: String? = nil, + endpoint: OpenAIService.Endpoint, + modelName: String, + contextWindow: Int, + maxToken: Int, + temperature: Double = 0.2, + stopWords: [String] = [], + apiKey: String + ) { + self.url = url.flatMap(URL.init(string:)) ?? { + switch endpoint { + case .chatCompletion: + URL(string: "https://api.openai.com/v1/chat/completions")! + case .completion: + URL(string: "https://api.openai.com/v1/completions")! + } + }() + + self.endpoint = endpoint + self.modelName = modelName + self.maxToken = maxToken + self.contextWindow = contextWindow + self.temperature = temperature + self.stopWords = stopWords + self.apiKey = apiKey + } +} + +extension AzureOpenAIService: CodeCompletionServiceType { + func getCompletion(_ request: PromptStrategy) async throws -> AsyncStream { + switch endpoint { + case .chatCompletion: + let messages = createMessages(from: request) + CodeCompletionLogger.logger.logPrompt(messages.map { + ($0.content, $0.role.rawValue) + }) + let result = try await sendMessages(messages) + return result.compactMap { $0.choices?.first?.delta?.content }.eraseToStream() + case .completion: + let prompt = createPrompt(from: request) + CodeCompletionLogger.logger.logPrompt([(prompt, "user")]) + let result = try await sendPrompt(prompt) + return result.compactMap { $0.choices?.first?.text }.eraseToStream() + } + } +} + +extension AzureOpenAIService { + public typealias Message = OpenAIService.Message + public typealias APIError = OpenAIService.APIError + public typealias Error = OpenAIService.Error + + func createMessages(from request: PromptStrategy) -> [Message] { + let strategy = DefaultTruncateStrategy(maxTokenLimit: max( + contextWindow / 3 * 2, + contextWindow - maxToken - 20 + )) + let prompts = strategy.createTruncatedPrompt(promptStrategy: request) + return [ + .init(role: .system, content: request.systemPrompt), + ] + prompts.map { prompt in + switch prompt.role { + case .user: + return .init(role: .user, content: prompt.content) + case .assistant: + return .init(role: .assistant, content: prompt.content) + } + } + } + + func sendMessages( + _ messages: [Message] + ) async throws -> ResponseStream { + let requestBody = OpenAIService.ChatCompletionRequestBody( + model: modelName, + messages: messages, + temperature: temperature, + stream: true, + stop: stopWords, + max_tokens: maxToken + ) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + let encoder = JSONEncoder() + request.httpBody = try encoder.encode(requestBody) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(apiKey, forHTTPHeaderField: "api-key") + let (result, response) = try await URLSession.shared.bytes(for: request) + + guard let response = response as? HTTPURLResponse else { + throw CancellationError() + } + + guard response.statusCode == 200 else { + let text = try await result.lines.reduce(into: "") { partialResult, current in + partialResult += current + } + throw Error.otherError(text) + } + + return ResponseStream(result: result) { + var text = $0 + if text.hasPrefix("data: ") { + text = String(text.dropFirst(6)) + } + do { + let chunk = try JSONDecoder().decode( + OpenAIService.ChatCompletionsStreamDataChunk.self, + from: text.data(using: .utf8) ?? Data() + ) + return .init(chunk: chunk, done: chunk.choices?.first?.finish_reason != nil) + } catch { + print(error) + throw error + } + } + } + + func createPrompt(from request: PromptStrategy) -> String { + let strategy = DefaultTruncateStrategy(maxTokenLimit: max( + contextWindow / 3 * 2, + contextWindow - maxToken - 20 + )) + let prompts = strategy.createTruncatedPrompt(promptStrategy: request) + return ([request.systemPrompt] + prompts.map(\.content)).joined(separator: "\n\n") + } + + func sendPrompt( + _ prompt: String + ) async throws -> ResponseStream { + let requestBody = OpenAIService.CompletionRequestBody( + model: modelName, + prompt: prompt, + temperature: temperature, + stream: true, + stop: stopWords, + max_tokens: maxToken + ) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + let encoder = JSONEncoder() + request.httpBody = try encoder.encode(requestBody) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(apiKey, forHTTPHeaderField: "api-key") + let (result, response) = try await URLSession.shared.bytes(for: request) + + guard let response = response as? HTTPURLResponse else { + throw CancellationError() + } + + guard response.statusCode == 200 else { + let text = try await result.lines.reduce(into: "") { partialResult, current in + partialResult += current + } + throw Error.otherError(text) + } + + return ResponseStream(result: result) { + var text = $0 + if text.hasPrefix("data: ") { + text = String(text.dropFirst(6)) + } + do { + let chunk = try JSONDecoder().decode( + OpenAIService.CompletionsStreamDataChunk.self, + from: text.data(using: .utf8) ?? Data() + ) + return .init(chunk: chunk, done: chunk.choices?.first?.finish_reason != nil) + } catch { + print(error) + throw error + } + } + } + + func countToken(_ message: Message) -> Int { + message.content.count + } +} + diff --git a/Tool/Sources/CustomSuggestionService/API/GoogleGeminiService.swift b/Tool/Sources/CustomSuggestionService/API/GoogleGeminiService.swift new file mode 100644 index 00000000..63ab980e --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/API/GoogleGeminiService.swift @@ -0,0 +1,127 @@ +import Foundation +import AIModel +import GoogleGenerativeAI + +public struct GoogleGeminiService { + let modelName: String + let maxToken: Int + let contextWindow: Int + let temperature: Double + let stopWords: [String] + let apiKey: String + + init( + modelName: String, + contextWindow: Int, + maxToken: Int, + temperature: Double = 0.2, + stopWords: [String] = [], + apiKey: String + ) { + self.modelName = modelName + self.maxToken = maxToken + self.contextWindow = contextWindow + self.temperature = temperature + self.stopWords = stopWords + self.apiKey = apiKey + } +} + +extension GoogleGeminiService: CodeCompletionServiceType { + func getCompletion(_ request: PromptStrategy) async throws -> AsyncStream { + let messages = createMessages(from: request) + CodeCompletionLogger.logger.logPrompt(messages.map { + ($0.parts.first?.text ?? "N/A", $0.role ?? "N/A") + }) + return AsyncStream { continuation in + let task = Task { + let result = try await sendMessages(messages) + try Task.checkCancellation() + continuation.yield(result) + continuation.finish() + } + continuation.onTermination = { _ in + task.cancel() + } + } + } +} + +public extension GoogleGeminiService { + enum KnownModels: String, CaseIterable { + case geminiPro = "gemini-pro" + + public var maxToken: Int { + switch self { + case .geminiPro: + return 32768 + } + } + } +} + +extension GoogleGeminiService { + public enum Error: Swift.Error, LocalizedError { + case apiError(Swift.Error) + case otherError(Swift.Error) + + public var errorDescription: String? { + switch self { + case let .apiError(error): + return "API error: \(error.localizedDescription)" + case let .otherError(error): + return "Error: \(error.localizedDescription)" + } + } + } + + func createMessages(from request: PromptStrategy) -> [ModelContent] { + let strategy = DefaultTruncateStrategy(maxTokenLimit: max( + contextWindow / 3 * 2, + contextWindow - maxToken - 20 + )) + let prompts = strategy.createTruncatedPrompt(promptStrategy: request) + return [ + .init( + role: "user", + parts: ([request.systemPrompt] + prompts.map(\.content)).joined(separator: "\n\n") + ), + ] + } + + func sendMessages(_ messages: [ModelContent]) async throws -> String { + let aiModel = GenerativeModel( + name: modelName, + apiKey: apiKey, + generationConfig: .init(GenerationConfig( + temperature: Float(temperature), + maxOutputTokens: maxToken, + stopSequences: stopWords + )) + ) + + do { + let response = try await aiModel.generateContent(messages) + + return response.candidates.first.map { + $0.content.parts.first(where: { part in + if let text = part.text { + return !text.isEmpty + } else { + return false + } + })?.text ?? "" + } ?? "" + } catch let error as GenerateContentError { + switch error { + case let .internalError(underlying): + throw Error.apiError(underlying) + default: + throw Error.apiError(error) + } + } catch { + throw Error.otherError(error) + } + } +} + diff --git a/Tool/Sources/CustomSuggestionService/API/MistralFIMService.swift b/Tool/Sources/CustomSuggestionService/API/MistralFIMService.swift new file mode 100644 index 00000000..9652f753 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/API/MistralFIMService.swift @@ -0,0 +1,148 @@ +import CopilotForXcodeKit +import Foundation +import AIModel + +public actor MistralFIMService { + let url: URL + let model: String + let temperature: Double + let stopWords: [String] + let apiKey: String + let contextWindow: Int + let maxToken: Int + + init( + url: URL? = nil, + model: String, + temperature: Double, + stopWords: [String] = [], + apiKey: String, + contextWindow: Int, + maxToken: Int + ) { + self.url = url ?? URL(string: "https://api.mistral.ai/v1/fim/completions")! + self.model = model + self.temperature = temperature + self.stopWords = stopWords + self.apiKey = apiKey + self.contextWindow = contextWindow + self.maxToken = maxToken + } +} + +extension MistralFIMService: CodeCompletionServiceType { + typealias CompletionSequence = AsyncThrowingCompactMapSequence< + ResponseStream, + String + > + + func getCompletion(_ request: any PromptStrategy) async throws -> CompletionSequence { + let result = try await send(request) + return result.compactMap { $0.choices?.first?.delta?.content ?? $0.choices?.first?.text } + } +} + +extension MistralFIMService { + struct RequestBody: Codable { + let model: String + let prompt: String + let suffix: String + let stream: Bool + let temperature: Double + let max_tokens: Int + } + + enum Error: Swift.Error, LocalizedError { + case decodeError(Swift.Error) + case otherError(String) + + public var errorDescription: String? { + switch self { + case let .decodeError(error): + return error.localizedDescription + case let .otherError(message): + return message + } + } + } + + struct StreamDataChunk: Decodable { + struct Delta: Decodable { + var role: OpenAIService.Message.Role? + var content: String? + } + + struct Choice: Decodable { + var index: Int + var delta: Delta? + var text: String? + var finish_reason: String? + } + + var id: String? + var object: String? + var model: String? + var choices: [Choice]? + } + + func send(_ request: any PromptStrategy) async throws -> ResponseStream { + let strategy = DefaultTruncateStrategy(maxTokenLimit: max( + contextWindow / 3 * 2, + contextWindow - maxToken - 20 + )) + let prompts = strategy.createTruncatedPrompt(promptStrategy: request) + + let prefix = prompts.first { $0.role == .prefix }?.content ?? "" + let suffix = prompts.last { $0.role == .suffix }?.content ?? "" + + CodeCompletionLogger.logger.logPrompt([ + (prefix, "prefix"), + (suffix, "suffix"), + ]) + + var request = URLRequest(url: url) + let requestBody = RequestBody( + model: model, + prompt: prefix, + suffix: suffix, + stream: true, + temperature: temperature, + max_tokens: maxToken + ) + let encoder = JSONEncoder() + request.httpBody = try encoder.encode(requestBody) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + let (result, response) = try await URLSession.shared.bytes(for: request) + + guard let response = response as? HTTPURLResponse else { + throw CancellationError() + } + + guard response.statusCode == 200 else { + let text = try await result.lines.reduce(into: "") { partialResult, current in + partialResult += current + } + throw Error.otherError(text) + } + + return ResponseStream(result: result) { + var text = $0 + if text.hasPrefix("data: ") { + text = String(text.dropFirst(6)) + } + do { + let chunk = try JSONDecoder().decode( + StreamDataChunk.self, + from: text.data(using: .utf8) ?? Data() + ) + return .init(chunk: chunk, done: chunk.choices?.first?.finish_reason != nil) + } catch { + print(error) + throw error + } + } + } +} + diff --git a/Tool/Sources/CustomSuggestionService/API/OllamaService.swift b/Tool/Sources/CustomSuggestionService/API/OllamaService.swift new file mode 100644 index 00000000..854e916e --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/API/OllamaService.swift @@ -0,0 +1,319 @@ +import CopilotForXcodeKit +import Foundation +import AIModel + +public actor OllamaService { + let url: URL + let endpoint: Endpoint + let modelName: String + let maxToken: Int + let contextWindow: Int + let temperature: Double + let stopWords: [String] + let keepAlive: String + let format: ResponseFormat + let authenticationMode: AuthenticationMode? + + enum AuthenticationMode { + case bearerToken(String) + case header(name: String, value: String) + } + + public enum ResponseFormat: String { + case none = "" + case json + } + + public enum Endpoint { + case completion + case chatCompletion + case completionWithSuffix + } + + init( + url: String? = nil, + endpoint: Endpoint, + modelName: String, + contextWindow: Int, + maxToken: Int, + temperature: Double = 0.2, + stopWords: [String] = [], + keepAlive: String = "", + format: ResponseFormat = .none, + authenticationMode: AuthenticationMode? = nil + ) { + self.url = url.flatMap(URL.init(string:)) ?? { + switch endpoint { + case .chatCompletion: + URL(string: "https://127.0.0.1:11434/api/chat")! + case .completion, .completionWithSuffix: + URL(string: "https://127.0.0.1:11434/api/generate")! + } + }() + + self.endpoint = endpoint + self.modelName = modelName + self.maxToken = maxToken + self.temperature = temperature + self.stopWords = stopWords + self.keepAlive = keepAlive + self.format = format + self.contextWindow = contextWindow + self.authenticationMode = authenticationMode + } +} + +extension OllamaService: CodeCompletionServiceType { + typealias CompletionSequence = AsyncThrowingCompactMapSequence< + ResponseStream, + String + > + + func getCompletion( + _ request: PromptStrategy + ) async throws -> CompletionSequence { + switch endpoint { + case .chatCompletion: + let messages = createMessages(from: request) + CodeCompletionLogger.logger.logPrompt(messages.map { + ($0.content, $0.role.rawValue) + }) + let stream = try await sendMessages(messages) + return stream.compactMap { $0.message?.content } + case .completion: + let prompt = createPrompt(from: request) + CodeCompletionLogger.logger.logPrompt([(prompt, "user")]) + let stream = try await sendPrompt(prompt, raw: request.promptIsRaw) + return stream.compactMap { $0.response } + case .completionWithSuffix: + let strategy = DefaultTruncateStrategy(maxTokenLimit: max( + contextWindow / 3 * 2, + contextWindow - maxToken - 20 + )) + let prompts = strategy.createTruncatedPrompt(promptStrategy: request) + + let prefix = prompts.first { $0.role == .prefix }?.content ?? "" + let suffix = prompts.last { $0.role == .suffix }?.content ?? "" + + CodeCompletionLogger.logger.logPrompt([ + (prefix, "prefix"), + (suffix, "suffix"), + ]) + + let stream = try await sendPrompt(prefix, suffix: suffix) + return stream.compactMap { $0.response } + } + } +} + +extension OllamaService { + struct Message: Codable, Equatable { + public enum Role: String, Codable { + case user + case assistant + case system + } + + /// The role of the message. + public var role: Role + /// The content of the message. + public var content: String + } + + enum Error: Swift.Error, LocalizedError { + case decodeError(Swift.Error) + case otherError(String) + + public var errorDescription: String? { + switch self { + case let .decodeError(error): + return error.localizedDescription + case let .otherError(message): + return message + } + } + } +} + +// MARK: - Chat Completion API + +/// https://github.com/ollama/ollama/blob/main/docs/api.md#chat-request-streaming +extension OllamaService { + struct ChatCompletionRequestBody: Codable { + struct Options: Codable { + var temperature: Double + var stop: [String] + var num_predict: Int + var top_k: Int? + var top_p: Double? + } + + var model: String + var messages: [Message] + var stream: Bool + var options: Options + var keep_alive: String? + var format: String? + } + + struct ChatCompletionResponseChunk: Decodable { + var model: String + var message: Message? + var response: String? + var done: Bool + var total_duration: Int64? + var load_duration: Int64? + var prompt_eval_count: Int? + var prompt_eval_duration: Int64? + var eval_count: Int? + var eval_duration: Int64? + } + + func createMessages(from request: PromptStrategy) -> [Message] { + let strategy = DefaultTruncateStrategy(maxTokenLimit: max( + contextWindow / 3 * 2, + contextWindow - maxToken - 20 + )) + let prompts = strategy.createTruncatedPrompt(promptStrategy: request) + return [ + .init(role: .system, content: request.systemPrompt), + ] + prompts.map { prompt in + switch prompt.role { + case .user: + return .init(role: .user, content: prompt.content) + case .assistant: + return .init(role: .assistant, content: prompt.content) + } + } + } + + func sendMessages(_ messages: [Message]) async throws + -> ResponseStream + { + let requestBody = ChatCompletionRequestBody( + model: modelName, + messages: messages, + stream: true, + options: .init( + temperature: temperature, + stop: stopWords, + num_predict: maxToken + ), + keep_alive: keepAlive.isEmpty ? nil : keepAlive, + format: format == .none ? nil : format.rawValue + ) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + let encoder = JSONEncoder() + request.httpBody = try encoder.encode(requestBody) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + let (result, response) = try await URLSession.shared.bytes(for: request) + + guard let response = response as? HTTPURLResponse else { + throw CancellationError() + } + + guard response.statusCode == 200 else { + let text = try await result.lines.reduce(into: "") { partialResult, current in + partialResult += current + } + throw Error.otherError(text) + } + + return ResponseStream(result: result) { + let chunk = try JSONDecoder().decode( + ChatCompletionResponseChunk.self, + from: $0.data(using: .utf8) ?? Data() + ) + return .init(chunk: chunk, done: chunk.done) + } + } +} + +// MARK: - Completion API + +extension OllamaService { + struct CompletionRequestBody: Codable { + var model: String + var prompt: String + var stream: Bool + var options: ChatCompletionRequestBody.Options + var keep_alive: String? + var format: String? + var raw: Bool? + var suffix: String? + } + + func createPrompt(from request: PromptStrategy) -> String { + let strategy = DefaultTruncateStrategy(maxTokenLimit: max( + contextWindow / 3 * 2, + contextWindow - maxToken - 20 + )) + let prompts = strategy.createTruncatedPrompt(promptStrategy: request) + return ([request.systemPrompt] + prompts.map(\.content)).joined(separator: "\n\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + func sendPrompt( + _ prompt: String, + raw: Bool? = nil, + suffix: String? = nil + ) async throws -> ResponseStream { + let requestBody = CompletionRequestBody( + model: modelName, + prompt: prompt, + stream: true, + options: .init( + temperature: temperature, + stop: stopWords, + num_predict: maxToken + ), + keep_alive: keepAlive.isEmpty ? nil : keepAlive, + format: format == .none ? nil : format.rawValue, + raw: raw, + suffix: suffix + ) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + let encoder = JSONEncoder() + request.httpBody = try encoder.encode(requestBody) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + switch authenticationMode{ + case .none: + break + case let .bearerToken(key): + request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") + case let .header(name, value): + request.setValue(value, forHTTPHeaderField: name) + } + + let (result, response) = try await URLSession.shared.bytes(for: request) + + guard let response = response as? HTTPURLResponse else { + throw CancellationError() + } + + guard response.statusCode == 200 else { + let text = try await result.lines.reduce(into: "") { partialResult, current in + partialResult += current + } + throw Error.otherError(text) + } + + return ResponseStream(result: result) { + let chunk = try JSONDecoder().decode( + ChatCompletionResponseChunk.self, + from: $0.data(using: .utf8) ?? Data() + ) + return .init(chunk: chunk, done: chunk.done) + } + } + + func countToken(_ message: Message) -> Int { + message.content.count + } +} + diff --git a/Tool/Sources/CustomSuggestionService/API/OpenAIService.swift b/Tool/Sources/CustomSuggestionService/API/OpenAIService.swift new file mode 100644 index 00000000..e06df362 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/API/OpenAIService.swift @@ -0,0 +1,476 @@ +import CopilotForXcodeKit +import Foundation +import AIModel + +public actor OpenAIService { + let url: URL + let endpoint: Endpoint + let modelName: String + let contextWindow: Int + let maxToken: Int + let temperature: Double + let apiKey: String + let stopWords: [String] + + public enum Endpoint { + case completion + case chatCompletion + } + + init( + url: String? = nil, + endpoint: Endpoint, + modelName: String, + contextWindow: Int, + maxToken: Int, + temperature: Double = 0.2, + stopWords: [String] = [], + apiKey: String + ) { + self.url = url.flatMap(URL.init(string:)) ?? { + switch endpoint { + case .chatCompletion: + URL(string: "https://api.openai.com/v1/chat/completions")! + case .completion: + URL(string: "https://api.openai.com/v1/completions")! + } + }() + + self.endpoint = endpoint + self.modelName = modelName + self.maxToken = maxToken + self.temperature = temperature + self.apiKey = apiKey + self.stopWords = stopWords + self.contextWindow = contextWindow + } +} + +extension OpenAIService: CodeCompletionServiceType { + func getCompletion(_ request: PromptStrategy) async throws -> AsyncStream { + switch endpoint { + case .chatCompletion: + let messages = createMessages(from: request) + CodeCompletionLogger.logger.logPrompt(messages.map { + ($0.content, $0.role.rawValue) + }) + let result = try await sendMessages(messages) + return result.compactMap { $0.choices?.first?.delta?.content }.eraseToStream() + case .completion: + let prompt = createPrompt(from: request) + CodeCompletionLogger.logger.logPrompt([(prompt, "user")]) + let result = try await sendPrompt(prompt) + return result.compactMap { $0.choices?.first?.text }.eraseToStream() + } + } +} + +public extension OpenAIService { + struct APIError: Decodable { + var message: String + var type: String + var param: String + var code: String + } + + enum Error: Swift.Error, LocalizedError { + case decodeError(Swift.Error) + case apiError(APIError) + case otherError(String) + + public var errorDescription: String? { + switch self { + case let .decodeError(error): + return error.localizedDescription + case let .apiError(error): + return error.message + case let .otherError(message): + return message + } + } + } + + func countToken(_ message: Message) -> Int { + message.content.count + } +} + +// MARK: - Chat Completion API + +extension OpenAIService { + public struct Message: Codable, Equatable { + public enum Role: String, Codable { + case user + case assistant + case system + } + + /// The role of the message. + public var role: Role + /// The content of the message. + public var content: String + } + + func createMessages(from request: PromptStrategy) -> [Message] { + let strategy = DefaultTruncateStrategy(maxTokenLimit: max( + contextWindow / 3 * 2, + contextWindow - maxToken - 20 + )) + let prompts = strategy.createTruncatedPrompt(promptStrategy: request) + return [ + .init(role: .system, content: request.systemPrompt), + ] + prompts.map { prompt in + switch prompt.role { + case .user: + return .init(role: .user, content: prompt.content) + case .assistant: + return .init(role: .assistant, content: prompt.content) + } + } + } + + func sendMessages( + _ messages: [Message] + ) async throws -> ResponseStream { + let requestBody = ChatCompletionRequestBody( + model: modelName, + messages: messages, + temperature: temperature, + stream: true, + stop: stopWords, + max_tokens: maxToken + ) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + let encoder = JSONEncoder() + request.httpBody = try encoder.encode(requestBody) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + let (result, response) = try await URLSession.shared.bytes(for: request) + + guard let response = response as? HTTPURLResponse else { + throw CancellationError() + } + + guard response.statusCode == 200 else { + let text = try await result.lines.reduce(into: "") { partialResult, current in + partialResult += current + } + throw Error.otherError(text) + } + + return ResponseStream(result: result) { + var text = $0 + if text.hasPrefix("data: ") { + text = String(text.dropFirst(6)) + } + do { + let chunk = try JSONDecoder().decode( + ChatCompletionsStreamDataChunk.self, + from: text.data(using: .utf8) ?? Data() + ) + return .init(chunk: chunk, done: chunk.choices?.first?.finish_reason != nil) + } catch { + print(error) + throw error + } + } + } + + /// https://platform.openai.com/docs/api-reference/chat/create + struct ChatCompletionRequestBody: Codable, Equatable { + var model: String + var messages: [Message] + var temperature: Double? + var top_p: Double? + var n: Double? + var stream: Bool? + var stop: [String]? + var max_tokens: Int? + var presence_penalty: Double? + var frequency_penalty: Double? + var logit_bias: [String: Double]? + + init( + model: String, + messages: [Message], + temperature: Double? = nil, + top_p: Double? = nil, + n: Double? = nil, + stream: Bool? = nil, + stop: [String]? = nil, + max_tokens: Int? = nil, + presence_penalty: Double? = nil, + frequency_penalty: Double? = nil, + logit_bias: [String: Double]? = nil + ) { + self.model = model + self.messages = messages + self.temperature = temperature + self.top_p = top_p + self.n = n + self.stream = stream + self.stop = stop + self.max_tokens = max_tokens + self.presence_penalty = presence_penalty + self.frequency_penalty = frequency_penalty + self.logit_bias = logit_bias + } + } + + struct ChatCompletionResponseBody: Codable, Equatable { + struct Choice: Codable, Equatable { + var message: Message + var index: Int + var finish_reason: String + } + + struct Usage: Codable, Equatable { + var prompt_tokens: Int + var completion_tokens: Int + var total_tokens: Int + } + + var id: String? + var object: String + var model: String + var usage: Usage + var choices: [Choice] + } + + struct ChatCompletionsStreamDataChunk: Decodable { + var id: String? + var object: String? + var model: String? + var choices: [Choice]? + + struct Choice: Decodable { + var delta: Delta? + var index: Int? + var finish_reason: String? + + struct Delta: Decodable { + var role: Message.Role? + var content: String? + } + } + } +} + +// MARK: - Completion API + +extension OpenAIService { + func createPrompt(from request: PromptStrategy) -> String { + let strategy = DefaultTruncateStrategy(maxTokenLimit: max( + contextWindow / 3 * 2, + contextWindow - maxToken - 20 + )) + let prompts = strategy.createTruncatedPrompt(promptStrategy: request) + // if request.systemPrompt empty not append + if request.systemPrompt.isEmpty { + return prompts.map(\.content).joined(separator: "\n\n") + } + return ([request.systemPrompt] + prompts.map(\.content)).joined(separator: "\n\n") + } + + func sendPrompt(_ prompt: String) async throws -> ResponseStream { + let requestBody = CompletionRequestBody( + model: modelName, + prompt: prompt, + temperature: temperature, + stream: true, + stop: stopWords, + max_tokens: maxToken + ) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + let encoder = JSONEncoder() + request.httpBody = try encoder.encode(requestBody) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + let (result, response) = try await URLSession.shared.bytes(for: request) + + guard let response = response as? HTTPURLResponse else { + throw CancellationError() + } + + guard response.statusCode == 200 else { + let text = try await result.lines.reduce(into: "") { partialResult, current in + partialResult += current + } + throw Error.otherError(text) + } + + return ResponseStream(result: result) { + var text = $0 + if text.hasPrefix("data: ") { + text = String(text.dropFirst(6)) + } + do { + let chunk = try JSONDecoder().decode( + CompletionsStreamDataChunk.self, + from: text.data(using: .utf8) ?? Data() + ) + return .init(chunk: chunk, done: chunk.choices?.first?.finish_reason != nil) + } catch { + print(error) + throw error + } + } + } + + /// https://platform.openai.com/docs/api-reference/chat/create + struct CompletionRequestBody: Codable, Equatable { + var model: String + var prompt: String + var temperature: Double? + var top_p: Double? + var n: Double? + var stream: Bool? + var stop: [String]? + /// Default to be 16. + var max_tokens: Int? + var presence_penalty: Double? + var frequency_penalty: Double? + var logit_bias: [String: Double]? + + init( + model: String, + prompt: String, + temperature: Double? = nil, + top_p: Double? = nil, + n: Double? = nil, + stream: Bool? = nil, + stop: [String]? = nil, + max_tokens: Int? = nil, + presence_penalty: Double? = nil, + frequency_penalty: Double? = nil, + logit_bias: [String: Double]? = nil + ) { + self.model = model + self.prompt = prompt + self.temperature = temperature + self.top_p = top_p + self.n = n + self.stream = stream + self.stop = stop + self.max_tokens = max_tokens + self.presence_penalty = presence_penalty + self.frequency_penalty = frequency_penalty + self.logit_bias = logit_bias + } + } + + struct CompletionResponseBody: Codable, Equatable { + struct Choice: Codable, Equatable { + var text: String + var index: Int + var finish_reason: String + } + + struct Usage: Codable, Equatable { + var prompt_tokens: Int + var total_tokens: Int + } + + var id: String? + var object: String + var model: String + var usage: Usage + var choices: [Choice] + } + + struct CompletionsStreamDataChunk: Decodable { + struct Choice: Decodable { + var text: String? + var index: Int + var finish_reason: String? + } + + var id: String? + var object: String? + var model: String? + var choices: [Choice]? + } +} + +// MARK: - Models + +public extension OpenAIService { + public enum ChatCompletionModels: String, CaseIterable { + case gpt35Turbo = "gpt-3.5-turbo" + case gpt35Turbo16k = "gpt-3.5-turbo-16k" + case gpt4 = "gpt-4" + case gpt432k = "gpt-4-32k" + case gpt4TurboPreview = "gpt-4-turbo-preview" + case gpt40314 = "gpt-4-0314" + case gpt40613 = "gpt-4-0613" + case gpt41106Preview = "gpt-4-1106-preview" + case gpt4VisionPreview = "gpt-4-vision-preview" + case gpt35Turbo0301 = "gpt-3.5-turbo-0301" + case gpt35Turbo0613 = "gpt-3.5-turbo-0613" + case gpt35Turbo1106 = "gpt-3.5-turbo-1106" + case gpt35Turbo0125 = "gpt-3.5-turbo-0125" + case gpt35Turbo16k0613 = "gpt-3.5-turbo-16k-0613" + case gpt432k0314 = "gpt-4-32k-0314" + case gpt432k0613 = "gpt-4-32k-0613" + case gpt40125 = "gpt-4-0125-preview" + } + + public enum CompletionModels: String, CaseIterable { + case gpt35TurboInstruct = "gpt-3.5-turbo-instruct" + } +} + +public extension OpenAIService.ChatCompletionModels { + public var maxToken: Int { + switch self { + case .gpt4: + return 8192 + case .gpt40314: + return 8192 + case .gpt432k: + return 32768 + case .gpt432k0314: + return 32768 + case .gpt35Turbo: + return 4096 + case .gpt35Turbo0301: + return 4096 + case .gpt35Turbo0613: + return 4096 + case .gpt35Turbo1106: + return 16385 + case .gpt35Turbo0125: + return 16385 + case .gpt35Turbo16k: + return 16385 + case .gpt35Turbo16k0613: + return 16385 + case .gpt40613: + return 8192 + case .gpt432k0613: + return 32768 + case .gpt41106Preview: + return 128_000 + case .gpt4VisionPreview: + return 128_000 + case .gpt4TurboPreview: + return 128_000 + case .gpt40125: + return 128_000 + } + } +} + +public extension OpenAIService.CompletionModels { + public var maxToken: Int { + switch self { + case .gpt35TurboInstruct: + return 4096 + } + } +} + diff --git a/Tool/Sources/CustomSuggestionService/API/TabbyService.swift b/Tool/Sources/CustomSuggestionService/API/TabbyService.swift new file mode 100644 index 00000000..1c013073 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/API/TabbyService.swift @@ -0,0 +1,138 @@ +import CopilotForXcodeKit +import Foundation +import AIModel + +actor TabbyService { + enum AuthorizationMode { + case none + case bearerToken(String) + case basic(username: String, password: String) + case customHeaderField(name: String, value: String) + } + + let url: URL + let temperature: Double + let authorizationMode: AuthorizationMode + + init( + url: String? = nil, + temperature: Double = 0.2, + authorizationMode: AuthorizationMode + ) { + self.url = url + .flatMap(URL.init(string:)) ?? URL(string: "http://127.0.0.1:8080/v1/completions")! + self.temperature = temperature + self.authorizationMode = authorizationMode + } +} + +extension TabbyService: CodeCompletionServiceType { + func getCompletion(_ request: PromptStrategy) async throws -> AsyncStream { + let prefix = request.prefix.joined() + let suffix = request.suffix.joined() + let requestBody = RequestBody( + language: request.language?.rawValue, + segments: .init( + prefix: prefix, + suffix: suffix, + clipboard: "" + ), + temperature: temperature, + seed: nil + ) + CodeCompletionLogger.logger.logPrompt([ + (prefix, "prefix"), + (suffix, "suffix"), + ]) + return AsyncStream { continuation in + let task = Task { + let result = try await send(requestBody) + try Task.checkCancellation() + continuation.yield(result) + continuation.finish() + } + continuation.onTermination = { _ in + task.cancel() + } + } + } +} + +extension TabbyService { + enum Error: Swift.Error, LocalizedError { + case serverError(String) + case decodeError(Swift.Error) + + var errorDescription: String? { + switch self { + case let .serverError(message): + return "Server returned an error: \(message)" + case let .decodeError(error): + return "Failed to decode response body: \(error)" + } + } + } + + struct RequestBody: Codable { + struct Segments: Codable { + var prefix: String + var suffix: String + var clipboard: String + } + + var language: String? + var segments: Segments + var temperature: Double + var seed: Int? + } + + struct ResponseBody: Codable { + struct Choice: Codable { + var index: Int + var text: String + } + + var id: String + var choices: [Choice] + } + + func send(_ requestBody: RequestBody) async throws -> String { + var request = URLRequest(url: url) + request.httpMethod = "POST" + let encoder = JSONEncoder() + request.httpBody = try encoder.encode(requestBody) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + switch authorizationMode { + case let .basic(username, password): + let data = "\(username):\(password)".data(using: .utf8)! + let base64 = data.base64EncodedString() + request.setValue("Basic \(base64)", forHTTPHeaderField: "Authorization") + case let .bearerToken(token): + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + case let .customHeaderField(name, value): + request.setValue(value, forHTTPHeaderField: name) + case .none: + break + } + + let (result, response) = try await URLSession.shared.data(for: request) + + guard let response = response as? HTTPURLResponse else { + throw CancellationError() + } + + guard response.statusCode == 200 else { + throw Error.serverError(String(data: result, encoding: .utf8) ?? "Unknown Error") + } + + do { + let body = try JSONDecoder().decode(ResponseBody.self, from: result) + return body.choices.first?.text ?? "" + } catch { + dump(error) + throw Error.decodeError(error) + } + } +} + diff --git a/Tool/Sources/CustomSuggestionService/CodeCompletionLogger.swift b/Tool/Sources/CustomSuggestionService/CodeCompletionLogger.swift new file mode 100644 index 00000000..dfb11776 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/CodeCompletionLogger.swift @@ -0,0 +1,139 @@ +import CopilotForXcodeKit +import Foundation +import Preferences +import Logger +import AIModel + +public final class CodeCompletionLogger { + struct Model { + var type: String + var format: String + var modelName: String + var baseURL: String + } + + @TaskLocal public static var logger: CodeCompletionLogger = .init(request: SuggestionRequest( + fileURL: .init(filePath: "/"), + relativePath: "", + language: .plaintext, + content: "", + originalContent: "", + cursorPosition: .zero, + tabSize: 0, + indentSize: 0, + usesTabsForIndentation: false, + relevantCodeSnippets: [] + )) + + let request: SuggestionRequest + var model = Model(type: "", format: "", modelName: "", baseURL: "") + var prompt: [(message: String, role: String)] = [] + var responses: [String] = [] + let startTime = Date() + let id = UUID() + + var shouldLogToConsole: Bool { + #if DEBUG + return true + #else + return UserDefaults.shared.value(for: \.customSuggestionVerboseLog) + #endif + } + + public init(request: SuggestionRequest) { + self.request = request + } + + public func logModel(_ chatModel: ChatModel) { + model = .init( + type: "Chat Completion", + format: chatModel.format.rawValue, + modelName: chatModel.info.modelName, + baseURL: chatModel.info.baseURL + ) + } + + public func logModel(_ completionModel: CompletionModel) { + model = .init( + type: "Chat Completion", + format: completionModel.format.rawValue, + modelName: completionModel.info.modelName, + baseURL: completionModel.info.baseURL + ) + } + + public func logModel(_ tabbyModel: TabbyModel) { + model = .init( + type: "Tabby", + format: "N/A", + modelName: "N/A", + baseURL: tabbyModel.url + ) + } + + public func logModel(_ fimModel: FIMModel) { + model = .init( + type: "FIM", + format: fimModel.format.rawValue, + modelName: fimModel.info.modelName, + baseURL: fimModel.info.baseURL + ) + } + + public func logPrompt(_ prompt: [(message: String, role: String)]) { + self.prompt = prompt + } + + public func logResponse(_ response: String) { + responses.append(response) + } + + public func error(_ error: Error) { + guard shouldLogToConsole else { return } + + let now = Date() + let duration = now.timeIntervalSince(startTime) + let formattedDuration = String(format: "%.2f", duration) + + Logger.service.info(""" + [Request] \(id) + + Duration: \(formattedDuration) + Error: \(error.localizedDescription). + """) + } + + public func finish() { + guard shouldLogToConsole else { return } + + let now = Date() + let duration = now.timeIntervalSince(startTime) + let formattedDuration = String(format: "%.2f", duration) + + Logger.service.info(""" + [Request] \(id) + + Format: \(model.format) + Model Name: \(model.modelName) + Base URL: \(model.baseURL) + Duration: \(formattedDuration) + --- + File URL: \(request.fileURL) + Code Snippets: \(request.relevantCodeSnippets.count) snippets + CursorPosition: \(request.cursorPosition) + """) + + Logger.service.info(""" + [Prompt] \(id) + + \(prompt.map { "\($0.role): \($0.message)" }.joined(separator: "\n\n")) + """) + + Logger.service.info(""" + [Response] \(id) + + \(responses.enumerated().map { "\($0 + 1): \($1)" }.joined(separator: "\n\n")) + """) + } +} + diff --git a/Tool/Sources/CustomSuggestionService/CodeCompletionService.swift b/Tool/Sources/CustomSuggestionService/CodeCompletionService.swift new file mode 100644 index 00000000..9b704f10 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/CodeCompletionService.swift @@ -0,0 +1,348 @@ +import Foundation +import AIModel +import Keychain +import Preferences + +protocol CodeCompletionServiceType { + associatedtype CompletionSequence: AsyncSequence where CompletionSequence.Element == String + + func getCompletion(_ request: PromptStrategy) async throws -> CompletionSequence +} + +extension CodeCompletionServiceType { + func getCompletions( + _ request: PromptStrategy, + streamStopStrategy: StreamStopStrategy, + count: Int + ) async throws -> [String] { + try await withThrowingTaskGroup(of: String.self) { group in + for _ in 0.. [String] { + let apiKey = apiKey(from: model) + + let service = TabbyService(url: model.url, authorizationMode: { + switch model.authorizationMode { + case .none: + return .none + case .bearerToken: + return .bearerToken(apiKey) + case .basic: + return .basic(username: model.username, password: apiKey) + case .customHeaderField: + return .customHeaderField(name: model.authorizationHeaderName, value: apiKey) + } + }()) + + let result = try await service.getCompletions( + prompt, + streamStopStrategy: streamStopStrategy, + count: count + ) + try Task.checkCancellation() + return result + } + + public func getCompletions( + _ prompt: PromptStrategy, + streamStopStrategy: StreamStopStrategy, + model: ChatModel, + count: Int + ) async throws -> [String] { + let apiKey = apiKey(from: model) + + switch model.format { + case .openAI, .openAICompatible: + let service = OpenAIService( + url: model.endpoint, + endpoint: .chatCompletion, + modelName: model.info.modelName, + contextWindow: model.info.maxTokens, + maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken), + stopWords: prompt.stopWords, + apiKey: apiKey + ) + let result = try await service.getCompletions( + prompt, + streamStopStrategy: streamStopStrategy, + count: count + ) + try Task.checkCancellation() + return result + case .azureOpenAI: + let service = AzureOpenAIService( + url: model.endpoint, + endpoint: .chatCompletion, + modelName: model.info.modelName, + contextWindow: model.info.maxTokens, + maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken), + stopWords: prompt.stopWords, + apiKey: apiKey + ) + let result = try await service.getCompletions( + prompt, + streamStopStrategy: streamStopStrategy, + count: count + ) + try Task.checkCancellation() + return result + case .googleAI: + let service = GoogleGeminiService( + modelName: model.info.modelName, + contextWindow: model.info.maxTokens, + maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken), + apiKey: apiKey + ) + let result = try await service.getCompletions( + prompt, + streamStopStrategy: streamStopStrategy, + count: count + ) + try Task.checkCancellation() + return result + case .ollama: + let service = OllamaService( + url: model.endpoint, + endpoint: .chatCompletion, + modelName: model.info.modelName, + contextWindow: model.info.maxTokens, + maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken), + stopWords: prompt.stopWords, + keepAlive: model.info.ollamaInfo.keepAlive, + format: .none + ) + let result = try await service.getCompletions( + prompt, + streamStopStrategy: streamStopStrategy, + count: count + ) + try Task.checkCancellation() + return result + case .claude: + let service = AnthropicService( + url: model.endpoint, + modelName: model.info.modelName, + contextWindow: model.info.maxTokens, + maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken), + stopWords: prompt.stopWords, + apiKey: apiKey + ) + let result = try await service.getCompletions( + prompt, + streamStopStrategy: streamStopStrategy, + count: count + ) + try Task.checkCancellation() + return result + case .gitHubCopilot: + throw Error.unknownFormat + } + } + + public func getCompletions( + _ prompt: PromptStrategy, + streamStopStrategy: StreamStopStrategy, + model: CompletionModel, + count: Int + ) async throws -> [String] { + let apiKey = apiKey(from: model) + + switch model.format { + case .openAI, .openAICompatible: + let service = OpenAIService( + url: model.endpoint, + endpoint: .completion, + modelName: model.info.modelName, + contextWindow: model.info.maxTokens, + maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken), + stopWords: prompt.stopWords, + apiKey: apiKey + ) + let result = try await service.getCompletions( + prompt, + streamStopStrategy: streamStopStrategy, + count: count + ) + try Task.checkCancellation() + return result + case .azureOpenAI: + let service = AzureOpenAIService( + url: model.endpoint, + endpoint: .completion, + modelName: model.info.modelName, + contextWindow: model.info.maxTokens, + maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken), + stopWords: prompt.stopWords, + apiKey: apiKey + ) + let result = try await service.getCompletions( + prompt, + streamStopStrategy: streamStopStrategy, + count: count + ) + try Task.checkCancellation() + return result + case .ollama: + let service = OllamaService( + url: model.endpoint, + endpoint: .completion, + modelName: model.info.modelName, + contextWindow: model.info.maxTokens, + maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken), + stopWords: prompt.stopWords, + keepAlive: model.info.ollamaInfo.keepAlive, + format: .none + ) + let result = try await service.getCompletions( + prompt, + streamStopStrategy: streamStopStrategy, + count: count + ) + try Task.checkCancellation() + return result + case .unknown: + throw Error.unknownFormat + } + } + + public func getCompletions( + _ prompt: PromptStrategy, + streamStopStrategy: StreamStopStrategy, + model: FIMModel, + count: Int + ) async throws -> [String] { + let apiKey = apiKey(from: model) + + switch model.format { + case .mistral: + let service = MistralFIMService( + url: URL(string: model.endpoint), + model: model.info.modelName, + temperature: 0, + stopWords: prompt.stopWords, + apiKey: apiKey, + contextWindow: model.info.maxTokens, + maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken) + ) + let result = try await service.getCompletions( + prompt, + streamStopStrategy: streamStopStrategy, + count: count + ) + try Task.checkCancellation() + return result + case .ollama: + let service = OllamaService( + url: model.endpoint, + endpoint: .completionWithSuffix, + modelName: model.info.modelName, + contextWindow: model.info.maxTokens, + maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken), + stopWords: prompt.stopWords, + keepAlive: model.info.ollamaInfo.keepAlive, + format: .none + ) + let result = try await service.getCompletions( + prompt, + streamStopStrategy: streamStopStrategy, + count: count + ) + try Task.checkCancellation() + return result + case .ollamaCompatible: + let service = OllamaService( + url: model.endpoint, + endpoint: .completionWithSuffix, + modelName: model.info.modelName, + contextWindow: model.info.maxTokens, + maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken), + stopWords: prompt.stopWords, + keepAlive: model.info.ollamaInfo.keepAlive, + format: .none, + authenticationMode: { + switch model.info.authenticationMode { + case .header: + return .header( + name: model.info.authenticationHeaderFieldName, + value: apiKey + ) + case .bearerToken: + return .bearerToken(apiKey) + } + }() + ) + let result = try await service.getCompletions( + prompt, + streamStopStrategy: streamStopStrategy, + count: count + ) + try Task.checkCancellation() + return result + case .unknown: + throw Error.unknownFormat + } + } + + func apiKey(from model: ChatModel) -> String { + let name = model.info.apiKeyName + return (try? Keychain.apiKey.get(name)) ?? "" + } + + func apiKey(from model: CompletionModel) -> String { + let name = model.info.apiKeyName + return (try? Keychain.apiKey.get(name)) ?? "" + } + + func apiKey(from model: TabbyModel) -> String { + let name = model.apiKeyName + return (try? Keychain.apiKey.get(name)) ?? "" + } + + func apiKey(from model: FIMModel) -> String { + let name = model.info.apiKeyName + return (try? Keychain.apiKey.get(name)) ?? "" + } +} + diff --git a/Tool/Sources/CustomSuggestionService/CustomModelExtension.swift b/Tool/Sources/CustomSuggestionService/CustomModelExtension.swift new file mode 100644 index 00000000..205e8118 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/CustomModelExtension.swift @@ -0,0 +1,22 @@ +import BuiltinExtension +import CopilotForXcodeKit +import Foundation + +/// A built-in extension that generates suggestions with user configured models +/// (OpenAI compatible APIs, Ollama, FIM endpoints, Tabby, etc.). +/// +/// It was the standalone "Custom Suggestion Service for Copilot for Xcode" app, now merged into +/// the host as a built-in suggestion provider. +public final class CustomModelExtension: BuiltinExtension { + public static let identifier = "com.intii.CopilotForXcode.CustomModel" + + public var extensionIdentifier: String { Self.identifier } + + public let suggestionService: CustomModelSuggestionService + + public init() { + suggestionService = .init() + } + + public func terminate() {} +} diff --git a/Tool/Sources/CustomSuggestionService/CustomModelSuggestionService.swift b/Tool/Sources/CustomSuggestionService/CustomModelSuggestionService.swift new file mode 100644 index 00000000..be2db160 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/CustomModelSuggestionService.swift @@ -0,0 +1,33 @@ +import CopilotForXcodeKit +import Foundation +import AIModel + +public final class CustomModelSuggestionService: SuggestionServiceType { + let service = CustomSuggestionCoordinator() + + public init() {} + + public var configuration: SuggestionServiceConfiguration { + .init( + acceptsRelevantCodeSnippets: true, + mixRelevantCodeSnippetsInSource: false, + acceptsRelevantSnippetsFromOpenedFiles: true + ) + } + + public func notifyAccepted(_ suggestion: CodeSuggestion, workspace: WorkspaceInfo) async {} + + public func notifyRejected(_ suggestions: [CodeSuggestion], workspace: WorkspaceInfo) async {} + + public func cancelRequest(workspace: WorkspaceInfo) async { + await service.cancelRequest() + } + + public func getSuggestions( + _ request: SuggestionRequest, + workspace: WorkspaceInfo + ) async throws -> [CodeSuggestion] { + try await service.getSuggestions(request, workspace: workspace) + } +} + diff --git a/Tool/Sources/CustomSuggestionService/CustomSuggestionCoordinator.swift b/Tool/Sources/CustomSuggestionService/CustomSuggestionCoordinator.swift new file mode 100644 index 00000000..06ff80ec --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/CustomSuggestionCoordinator.swift @@ -0,0 +1,227 @@ +import CopilotForXcodeKit +import Foundation +import Preferences +import AIModel + +actor CustomSuggestionCoordinator { + enum Model { + case chatModel(ChatModel) + case completionModel(CompletionModel) + case tabbyModel(TabbyModel) + case fimModel(FIMModel) + } + + var onGoingTask: Task<[CodeSuggestion], Error>? + + func cancelRequest() { + onGoingTask?.cancel() + onGoingTask = nil + } + + func getSuggestions( + _ request: SuggestionRequest, + workspace: WorkspaceInfo + ) async throws -> [CodeSuggestion] { + onGoingTask?.cancel() + onGoingTask = nil + let task = Task { + try await CodeCompletionLogger.$logger.withValue(.init(request: request)) { + do { + let lines = request.content.breakLines() + let (previousLines, nextLines) = Self.split( + code: request.content, + lines: lines, + at: request.cursorPosition + ) + let strategy = getStrategy( + sourceRequest: request, + prefix: previousLines, + suffix: nextLines + ) + + if strategy.shouldSkip { + throw CancellationError() + } + + let service = CodeCompletionService() + let model = getModel() + let prompt = strategy.createPrompt() + let postProcessor = strategy.createRawSuggestionPostProcessor() + let stopStream = strategy.createStreamStopStrategy(model: model) + + let suggestedCodeSnippets: [String] + + switch model { + case let .chatModel(model): + CodeCompletionLogger.logger.logModel(model) + suggestedCodeSnippets = try await service.getCompletions( + prompt, + streamStopStrategy: stopStream, + model: model, + count: 1 + ) + case let .completionModel(model): + CodeCompletionLogger.logger.logModel(model) + suggestedCodeSnippets = try await service.getCompletions( + prompt, + streamStopStrategy: stopStream, + model: model, + count: 1 + ) + case let .tabbyModel(model): + CodeCompletionLogger.logger.logModel(model) + suggestedCodeSnippets = try await service.getCompletions( + prompt, + streamStopStrategy: stopStream, + model: model, + count: 1 + ) + case let .fimModel(model): + CodeCompletionLogger.logger.logModel(model) + suggestedCodeSnippets = try await service.getCompletions( + prompt, + streamStopStrategy: stopStream, + model: model, + count: 1 + ) + } + + CodeCompletionLogger.logger.finish() + + return suggestedCodeSnippets + .filter { !$0.allSatisfy { $0.isWhitespace || $0.isNewline } } + .map { + let suggestionText = postProcessor + .postProcess( + rawSuggestion: $0, + infillPrefix: prompt.suggestionPrefix.prependingValue, + suffix: prompt.suffix + ) + .keepLines( + count: UserDefaults.shared + .value(for: \.customSuggestionMaxLines) + ) + .removeTrailingNewlinesAndWhitespace() + + return CodeSuggestion( + id: UUID().uuidString, + text: suggestionText, + position: request.cursorPosition, + range: .init( + start: .init( + line: request.cursorPosition.line, + character: 0 + ), + end: request.cursorPosition + ) + ) + } + } catch { + CodeCompletionLogger.logger.error(error) + throw error + } + } + } + onGoingTask = task + return try await task.value + } + + func getModel() -> Model { + let id = UserDefaults.shared.value(for: \.customSuggestionModelId) + let models = UserDefaults.shared.value(for: \.chatModels) + if let existedModel = models.first(where: { $0.id == id }) { + return .chatModel(existedModel) + } + let type = CustomModelType(rawValue: id) ?? .default + switch type { + case .completionModel: + return .completionModel(UserDefaults.shared.value(for: \.customSuggestionCompletionModel)) + case .tabby: + return .tabbyModel(UserDefaults.shared.value(for: \.customSuggestionTabbyModel)) + case .fimModel: + return .fimModel(UserDefaults.shared.value(for: \.customSuggestionFIMModel)) + } + } + + func getStrategy( + sourceRequest: SuggestionRequest, + prefix: [String], + suffix: [String] + ) -> any RequestStrategy { + let id = UserDefaults.shared.value(for: \.customSuggestionRequestStrategyId) + let type = CustomModelType(rawValue: UserDefaults.shared.value(for: \.customSuggestionModelId)) + if let type, type == .tabby { + return TabbyRequestStrategy( + sourceRequest: sourceRequest, + prefix: prefix, + suffix: suffix + ) + } + if let type, type == .fimModel { + return FIMEndpointRequestStrategy( + sourceRequest: sourceRequest, + prefix: prefix, + suffix: suffix + ) + } + let strategyOption = RequestStrategyOption(rawValue: id) ?? .default + return strategyOption.strategy.init( + sourceRequest: sourceRequest, + prefix: prefix, + suffix: suffix + ) + } + + static func split( + code: String, + lines: [String], + at cursorPosition: CursorPosition + ) -> (head: [String], tail: [String]) { + if code.isEmpty { return ([], []) } + if lines.isEmpty { return ([], []) } + if cursorPosition.line < 0 { return ([], lines) } + if cursorPosition.line >= lines.endIndex { return (lines, []) } + + let (previousLines, nextLines): ([String], [String]) = { + let previousLines = Array(lines[0..= lines.endIndex + ? [] + : Array(lines[(cursorPosition.line + 1)...]) + let splitLine = lines[cursorPosition.line] + if cursorPosition.character < 0 { + return (previousLines, [splitLine] + nextLines) + } + if cursorPosition.character >= splitLine.count { + return (previousLines + [splitLine], nextLines) + } + let firstHalf = String(splitLine[.. String { + if count <= 0 { return self } + let lines = breakLines() + return lines.prefix(count).joined() + } + + func removeTrailingNewlinesAndWhitespace() -> String { + var text = self[...] + while let last = text.last, last.isNewline || last.isWhitespace { + text = text.dropLast(1) + } + return String(text) + } +} + diff --git a/Tool/Sources/CustomSuggestionService/PromptStrategy.swift b/Tool/Sources/CustomSuggestionService/PromptStrategy.swift new file mode 100644 index 00000000..86d44796 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/PromptStrategy.swift @@ -0,0 +1,100 @@ +import CopilotForXcodeKit +import Foundation + +public protocol PromptStrategy { + /// An instruction to the AI model to generate a completion. + var systemPrompt: String { get } + /// The source code before the text cursor. Represented as an array of lines. + var prefix: [String] { get } + /// The source code after the text cursor. Represented as an array of lines. + var suffix: [String] { get } + /// The prefix that should be prepended to the response. By default the last element of + /// `prefix`. + var suggestionPrefix: SuggestionPrefix { get } + /// The relevant code snippets that the AI model should consider when generating a completion. + var relevantCodeSnippets: [RelevantCodeSnippet] { get } + /// The words at which the AI model should stop generating the completion. + var stopWords: [String] { get } + /// The language of the source code. + var language: CodeLanguage? { get } + /// If the prompt generated is raw. + var promptIsRaw: Bool { get } + + /// Creates a prompt about the source code and relevant code snippets to be sent to the AI + /// model. + /// + /// - Parameters: + /// - truncatedPrefix: The truncated source code before the text cursor. + /// - truncatedSuffix: The truncated source code after the text cursor. + /// - includedSnippets: The relevant code snippets to be included in the prompt. + /// + /// - Warning: Please make sure that the prompt won't cause the whole prompt to + /// exceed the token limit. + func createPrompt( + truncatedPrefix: [String], + truncatedSuffix: [String], + includedSnippets: [RelevantCodeSnippet] + ) -> [PromptMessage] +} + +/// A meesage in prompt. +public struct PromptMessage { + public enum PromptRole { + case user + case assistant + public static var prefix: PromptRole { .user } + public static var suffix: PromptRole { .assistant } + } + + public var role: PromptRole + public var content: String + + public init(role: PromptRole, content: String) { + self.role = role + self.content = content + } +} + +/// The last line of the prefix. +public struct SuggestionPrefix { + /// The original value. + public var original: String + /// The value to be in the prompt. This value can be different than the ``original`` value. Use + /// it to tweak the prompt to make the AI model generate a better completion. + /// + /// For example, it the last character is `{`, we may want to start the generation from the + /// next line. + public var infillValue: String + /// The value to be prepended to the response that is generated from the ``infillValue``. + /// + /// For example, if we appended `// write some code` in the ``infillValue`` to make the model + /// generate code instead of comments, we may not want to include this line in the final + /// suggestion. + public var prependingValue: String + + public static var empty: SuggestionPrefix { + .init(original: "", infillValue: "", prependingValue: "") + } + + public static func unchanged(_ string: String) -> SuggestionPrefix { + .init(original: string, infillValue: string, prependingValue: string) + } + + public init(original: String, infillValue: String, prependingValue: String) { + self.original = original + self.infillValue = infillValue + self.prependingValue = prependingValue + } +} + +// MARK: - Default Implementations + +public extension PromptStrategy { + var suggestionPrefix: SuggestionPrefix { + guard let prefix = prefix.last else { return .empty } + return .unchanged(prefix) + } + + var promptIsRaw: Bool { false } +} + diff --git a/Tool/Sources/CustomSuggestionService/RawSuggestionPostProcessing/DefaultRawSuggestionPostProcessingStrategy.swift b/Tool/Sources/CustomSuggestionService/RawSuggestionPostProcessing/DefaultRawSuggestionPostProcessingStrategy.swift new file mode 100644 index 00000000..d62360dc --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/RawSuggestionPostProcessing/DefaultRawSuggestionPostProcessingStrategy.swift @@ -0,0 +1,160 @@ +import Foundation +import Parsing + +protocol RawSuggestionPostProcessingStrategy { + func postProcess(rawSuggestion: String, infillPrefix: String, suffix: [String]) -> String +} + +struct DefaultRawSuggestionPostProcessingStrategy: RawSuggestionPostProcessingStrategy { + let codeWrappingTags: (opening: String, closing: String)? + + func postProcess(rawSuggestion: String, infillPrefix: String, suffix: [String]) -> String { + var suggestion = extractSuggestion(from: rawSuggestion) + removePrefix(from: &suggestion, infillPrefix: infillPrefix) + removeSuffix(from: &suggestion, suffix: suffix) + return infillPrefix + suggestion + } + + func extractSuggestion(from response: String) -> String { + let escapedMarkdownCodeBlock = removeLeadingAndTrailingMarkdownCodeBlockMark(from: response) + if let tags = codeWrappingTags { + let escapedTags = extractEnclosingSuggestion( + from: escapedMarkdownCodeBlock, + openingTag: tags.opening, + closingTag: tags.closing + ) + return escapedTags + } else { + return escapedMarkdownCodeBlock + } + } + + func removePrefix(from suggestion: inout String, infillPrefix: String) { + if suggestion.hasPrefix(infillPrefix) { + suggestion.removeFirst(infillPrefix.count) + } + } + + /// Window-mapping the lines in suggestion and the suffix to remove the common suffix. + func removeSuffix(from suggestion: inout String, suffix: [String]) { + let suggestionLines = suggestion.breakLines(appendLineBreakToLastLine: true) + if let last = suggestionLines.last, let lastIndex = suffix.firstIndex(of: last) { + var i = lastIndex - 1 + var j = suggestionLines.endIndex - 2 + while i >= 0, j >= 0, suffix[i] == suggestionLines[j] { + i -= 1 + j -= 1 + } + if i < 0 { + let endIndex = max(j, 0) + suggestion = suggestionLines[...endIndex].joined() + } + } + } + + /// Extract suggestions that is enclosed in tags. + fileprivate func extractEnclosingSuggestion( + from response: String, + openingTag: String, + closingTag: String + ) -> String { + guard !openingTag.isEmpty, !closingTag.isEmpty else { + return response + } + + let case_openingTagAtTheStart_parseEverythingInsideTheTag = Parse(input: Substring.self) { + openingTag + + OneOf { // parse until tags or the end + Parse { + OneOf { + PrefixUpTo(openingTag) + PrefixUpTo(closingTag) + } + Skip { + Rest() + } + } + + Rest() + } + } + + let case_noTagAtTheStart_parseEverythingBeforeTheTag = Parse(input: Substring.self) { + OneOf { + PrefixUpTo(openingTag) + PrefixUpTo(closingTag) + } + + Skip { + Rest() + } + } + + let parser = Parse(input: Substring.self) { + OneOf { + case_openingTagAtTheStart_parseEverythingInsideTheTag + case_noTagAtTheStart_parseEverythingBeforeTheTag + Rest() + } + } + + var text = response[...] + do { + let suggestion = try parser.parse(&text) + return String(suggestion) + } catch { + return response + } + } + + /// If the response starts with markdown code block, we should remove it. + fileprivate func removeLeadingAndTrailingMarkdownCodeBlockMark(from response: String) + -> String + { + let leadingMarkdownCodeBlockMarkParser = Parse(input: Substring.self) { + Skip { + Many { + OneOf { + " " + "\n" + } + } + "```" + } + } + + let messagePrefixingMarkdownCodeBlockMarkParser = Parse(input: Substring.self) { + Skip { + PrefixThrough(":") + "\n```" + } + } + + let removePrefixMarkdownCodeBlockMark = Parse(input: Substring.self) { + Skip { + OneOf { + leadingMarkdownCodeBlockMarkParser + messagePrefixingMarkdownCodeBlockMarkParser + } + PrefixThrough("\n") + } + OneOf { + Parse { + PrefixUpTo("```") + Skip { Rest() } + } + Rest() + } + } + + do { + var response = response[...] + let suggestion = try removePrefixMarkdownCodeBlockMark.parse(&response) + return String(suggestion) + } catch { + return response + } + } +} + diff --git a/Tool/Sources/CustomSuggestionService/RawSuggestionPostProcessing/NoOpRawSuggestionPostProcessingStrategy.swift b/Tool/Sources/CustomSuggestionService/RawSuggestionPostProcessing/NoOpRawSuggestionPostProcessingStrategy.swift new file mode 100644 index 00000000..89b7eac7 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/RawSuggestionPostProcessing/NoOpRawSuggestionPostProcessingStrategy.swift @@ -0,0 +1,8 @@ +import Foundation + +struct NoOpRawSuggestionPostProcessingStrategy: RawSuggestionPostProcessingStrategy { + func postProcess(rawSuggestion: String, infillPrefix: String, suffix: [String]) -> String { + infillPrefix + rawSuggestion + } +} + diff --git a/Tool/Sources/CustomSuggestionService/RequestStrategies/AnthropicRequestStrategy.swift b/Tool/Sources/CustomSuggestionService/RequestStrategies/AnthropicRequestStrategy.swift new file mode 100644 index 00000000..5cc53ec6 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/RequestStrategies/AnthropicRequestStrategy.swift @@ -0,0 +1,161 @@ +import CopilotForXcodeKit +import Foundation +import AIModel + +/// Request strategy optimized for Anthropic's Claude API. +/// +/// This strategy is specifically designed to work with Claude's message API format, +/// providing clear and strict instructions for code completion tasks. +struct AnthropicRequestStrategy: RequestStrategy { + var sourceRequest: SuggestionRequest + var prefix: [String] + var suffix: [String] + + var shouldSkip: Bool { + prefix.last?.trimmingCharacters(in: .whitespaces) == "}" + } + + func createPrompt() -> Prompt { + Prompt( + sourceRequest: sourceRequest, + prefix: prefix, + suffix: suffix + ) + } + + func createStreamStopStrategy(model _: CustomSuggestionCoordinator.Model) -> some StreamStopStrategy { + OpeningTagBasedStreamStopStrategy( + openingTag: Tag.openingCode, + toleranceIfNoOpeningTagFound: 0 // Claude is more precise, we don't need tolerance + ) + } + + func createRawSuggestionPostProcessor() -> DefaultRawSuggestionPostProcessingStrategy { + DefaultRawSuggestionPostProcessingStrategy(codeWrappingTags: ( + Tag.openingCode, + Tag.closingCode + )) + } + + enum Tag { + public static let openingCode = "" + public static let closingCode = "" + public static let openingSnippet = "" + public static let closingSnippet = "" + } + + struct Prompt: PromptStrategy { + let systemPrompt: String = """ + You are a code completion AI with the following STRICT rules: + 1. You MUST ONLY output code within \(Tag.openingCode) and \(Tag.closingCode) tags + 2. You MUST NEVER add explanations, comments, or any text outside the tags + 3. You MUST continue the code exactly where it was left off + 4. You MUST maintain the same style, patterns, and conventions present in the surrounding code + 5. You MUST NOT include any markdown formatting or code block symbols + + Example - if given: + Complete code inside \(Tag.openingCode): + + \(Tag.openingCode) + print("Hello + \(Tag.closingCode) + + You MUST respond EXACTLY: + \(Tag.openingCode) World")\(Tag.closingCode) + + CRITICAL REQUIREMENTS: + - Start IMMEDIATELY with \(Tag.openingCode) + - End IMMEDIATELY with \(Tag.closingCode) + - NO text before or after the tags + - NO explanations + - NO markdown + - NO commentary + """.trimmingCharacters(in: .whitespacesAndNewlines) + + var sourceRequest: SuggestionRequest + var prefix: [String] + var suffix: [String] + var filePath: String { sourceRequest.relativePath ?? sourceRequest.fileURL.path } + var relevantCodeSnippets: [RelevantCodeSnippet] { sourceRequest.relevantCodeSnippets } + var stopWords: [String] { [Tag.closingCode] } // Removed "\n\n" as Claude does not accept whitespace-only sequences + var language: CodeLanguage? { sourceRequest.language } + + var suggestionPrefix: SuggestionPrefix { + guard let prefix = prefix.last else { return .empty } + return .unchanged(prefix).curlyBracesLineBreak() + } + + func createPrompt( + truncatedPrefix: [String], + truncatedSuffix: [String], + includedSnippets: [RelevantCodeSnippet] + ) -> [PromptMessage] { + return [.init(role: .user, content: [ + Self.createSnippetsPrompt(includedSnippets: includedSnippets), + createSourcePrompt( + truncatedPrefix: truncatedPrefix, + truncatedSuffix: truncatedSuffix + ), + ].filter { !$0.isEmpty }.joined(separator: "\n\n"))] + } + + func createSourcePrompt(truncatedPrefix: [String], truncatedSuffix: [String]) -> String { + guard let (summary, infillBlock) = Self.createCodeSummary( + truncatedPrefix: truncatedPrefix, + truncatedSuffix: truncatedSuffix, + suggestionPrefix: suggestionPrefix.infillValue + ) else { return "" } + + return """ + Below is the code from file \(filePath) that needs completion. + You MUST: + 1. Analyze the code's style, patterns, and conventions + 2. Complete the code maintaining exact formatting + 3. Only output code within \(Tag.openingCode) tags + 4. Never duplicate existing implementations + + File: \(filePath) + Indentation: \(sourceRequest.indentSize) \(sourceRequest.usesTabsForIndentation ? "tab" : "space") + + Code to complete: + \(summary) + + Complete code inside \(Tag.openingCode): + + \(Tag.openingCode)\(infillBlock) + """.trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func createSnippetsPrompt(includedSnippets: [RelevantCodeSnippet]) -> String { + guard !includedSnippets.isEmpty else { return "" } + return """ + Reference code (analyze for patterns and conventions): + + \(includedSnippets.map { snippet in + "\(Tag.openingSnippet)\n\(snippet.content)\n\(Tag.closingSnippet)" + }.joined(separator: "\n\n")) + """.trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func createCodeSummary( + truncatedPrefix: [String], + truncatedSuffix: [String], + suggestionPrefix: String + ) -> (summary: String, infillBlock: String)? { + guard !(truncatedPrefix.isEmpty && truncatedSuffix.isEmpty) else { return nil } + let promptLinesCount = min(10, max(truncatedPrefix.count, 2)) + let prefixLines = truncatedPrefix.prefix( + max(0, truncatedPrefix.count - promptLinesCount) + ) + let promptLines: [String] = { + let proposed = truncatedPrefix.suffix(promptLinesCount) + return Array(proposed.dropLast()) + [suggestionPrefix] + }() + + return ( + summary: "\(prefixLines.joined())\(Tag.openingCode)\(Tag.closingCode)\(truncatedSuffix.joined())", + infillBlock: promptLines.joined() + ) + } + } +} diff --git a/Tool/Sources/CustomSuggestionService/RequestStrategies/ContinueRequestStrategy.swift b/Tool/Sources/CustomSuggestionService/RequestStrategies/ContinueRequestStrategy.swift new file mode 100644 index 00000000..e79ae596 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/RequestStrategies/ContinueRequestStrategy.swift @@ -0,0 +1,176 @@ +import CopilotForXcodeKit +import Foundation +import AIModel + +/// This strategy tries to fool the AI model that it has generated a part of the response but fail +/// to complete because of token limit. The strategy will append a user message "Continue" to let +/// the model continue it's (mock) response, so that the format may be more stable. +struct ContinueRequestStrategy: RequestStrategy { + var sourceRequest: SuggestionRequest + var prefix: [String] + var suffix: [String] + + var shouldSkip: Bool { + prefix.last?.trimmingCharacters(in: .whitespaces) == "}" + } + + func createPrompt() -> Prompt { + Prompt( + sourceRequest: sourceRequest, + prefix: prefix, + suffix: suffix + ) + } + + func createRawSuggestionPostProcessor() -> DefaultRawSuggestionPostProcessingStrategy { + DefaultRawSuggestionPostProcessingStrategy(codeWrappingTags: ( + Tag.openingCode, + Tag.closingCode + )) + } + + func createStreamStopStrategy(model: CustomSuggestionCoordinator.Model) -> some StreamStopStrategy { + OpeningTagBasedStreamStopStrategy( + openingTag: Tag.openingCode, + toleranceIfNoOpeningTagFound: { if case .chatModel = model { 4 } else { 0 } }() + ) + } + + enum Tag { + public static let openingCode = "" + public static let closingCode = "" + public static let openingSnippet = "" + public static let closingSnippet = "" + } + + struct Prompt: PromptStrategy { + let systemPrompt: String = """ + You are a senior programer who take the surrounding code and \ + references from the codebase into account in order to write high-quality code to \ + complete the code enclosed in \(Tag.openingCode) tags. \ + You only respond with code that works and fits seamlessly with surrounding code. \ + Do not include anything else beyond the code. + + When you are asked to continue generating, you should continue generating the response. + For example, if your previous response is: + ``` + print(Hell + ``` + + You should continue with: + ``` + o World) + ``` + """.trimmingCharacters(in: .whitespacesAndNewlines) + var sourceRequest: SuggestionRequest + var prefix: [String] + var suffix: [String] + var filePath: String { sourceRequest.relativePath ?? sourceRequest.fileURL.path } + var relevantCodeSnippets: [RelevantCodeSnippet] { sourceRequest.relevantCodeSnippets } + var stopWords: [String] { [Tag.closingCode, "\n\n"] } + var language: CodeLanguage? { sourceRequest.language } + + var suggestionPrefix: SuggestionPrefix { + guard let prefix = prefix.last else { return .empty } + return .unchanged(prefix).curlyBracesLineBreak() + } + + func createPrompt( + truncatedPrefix: [String], + truncatedSuffix: [String], + includedSnippets: [RelevantCodeSnippet] + ) -> [PromptMessage] { + return createSourcePrompt( + truncatedPrefix: truncatedPrefix, + truncatedSuffix: truncatedSuffix, + includedSnippets: includedSnippets + ) + } + + func createSourcePrompt( + truncatedPrefix: [String], + truncatedSuffix: [String], + includedSnippets: [RelevantCodeSnippet] + ) -> [PromptMessage] { + guard let (summary, infillBlock) = Self.createCodeSummary( + truncatedPrefix: truncatedPrefix, + truncatedSuffix: truncatedSuffix, + suggestionPrefix: suggestionPrefix.infillValue + ) else { return [] } + + let snippets = Self.createSnippetsPrompt(includedSnippets: includedSnippets) + + let initialPrompt = PromptMessage(role: .user, content: """ + \(snippets) + + Below is the code from file \(filePath) that you are trying to complete. + Review the code carefully, detect the functionality, formats, style, patterns, \ + and logics in use and use them to predict the completion. \ + Make sure your completion has the correct syntax and formatting. + + File Path: \(filePath) + Indentation: \ + \(sourceRequest.indentSize) \(sourceRequest.usesTabsForIndentation ? "tab" : "space") + + --- + + Here is the code: + ``` + \(summary) + ``` + + Complete code inside \(Tag.openingCode) + """.trimmingCharacters(in: .whitespacesAndNewlines)) + + let mockResponse = PromptMessage(role: .assistant, content: """ + \(Tag.openingCode)\(infillBlock) + """.trimmingCharacters(in: .whitespacesAndNewlines)) + + let continuePrompt = PromptMessage(role: .user, content: """ + Continue generating. \ + Don't duplicate existing implementations. \ + Don't try to fix what was written. \ + Don't worry about typos. + """.trimmingCharacters(in: .whitespacesAndNewlines)) + + return [ + initialPrompt, + mockResponse, + continuePrompt, + ] + } + + static func createSnippetsPrompt(includedSnippets: [RelevantCodeSnippet]) -> String { + guard !includedSnippets.isEmpty else { return "" } + var content = "References from codebase: \n\n" + for snippet in includedSnippets { + content += """ + \(Tag.openingSnippet) + \(snippet.content) + \(Tag.closingSnippet) + """ + "\n\n" + } + return content.trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func createCodeSummary( + truncatedPrefix: [String], + truncatedSuffix: [String], + suggestionPrefix: String + ) -> (summary: String, infillBlock: String)? { + guard !(truncatedPrefix.isEmpty && truncatedSuffix.isEmpty) else { return nil } + let promptLinesCount = min(4, max(truncatedPrefix.count, 2)) + let prefixLines = truncatedPrefix.prefix(truncatedPrefix.count - promptLinesCount) + let promptLines: [String] = { + let proposed = truncatedPrefix.suffix(promptLinesCount) + return Array(proposed.dropLast()) + [suggestionPrefix] + }() + + return ( + summary: "\(prefixLines.joined())\(Tag.openingCode)\(Tag.closingCode)\(truncatedSuffix.joined())", + infillBlock: promptLines.joined() + ) + } + } +} + diff --git a/Tool/Sources/CustomSuggestionService/RequestStrategies/DefaultRequestStrategy.swift b/Tool/Sources/CustomSuggestionService/RequestStrategies/DefaultRequestStrategy.swift new file mode 100644 index 00000000..5f444713 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/RequestStrategies/DefaultRequestStrategy.swift @@ -0,0 +1,162 @@ +import CopilotForXcodeKit +import Foundation +import AIModel + +/// The default strategy to generate prompts. +/// +/// This strategy tries to believe that the model is smart. It will explain carefully what is what +/// and tell the model to complete the code. +struct DefaultRequestStrategy: RequestStrategy { + var sourceRequest: SuggestionRequest + var prefix: [String] + var suffix: [String] + + var shouldSkip: Bool { + prefix.last?.trimmingCharacters(in: .whitespaces) == "}" + } + + func createPrompt() -> Prompt { + Prompt( + sourceRequest: sourceRequest, + prefix: prefix, + suffix: suffix + ) + } + + func createStreamStopStrategy(model: CustomSuggestionCoordinator.Model) -> some StreamStopStrategy { + OpeningTagBasedStreamStopStrategy( + openingTag: Tag.openingCode, + toleranceIfNoOpeningTagFound: { if case .chatModel = model { 4 } else { 0 } }() + ) + } + + func createRawSuggestionPostProcessor() -> DefaultRawSuggestionPostProcessingStrategy { + DefaultRawSuggestionPostProcessingStrategy(codeWrappingTags: ( + Tag.openingCode, + Tag.closingCode + )) + } + + enum Tag { + public static let openingCode = "" + public static let closingCode = "" + public static let openingSnippet = "" + public static let closingSnippet = "" + } + + struct Prompt: PromptStrategy { + let systemPrompt: String = """ + You are a senior programer who take the surrounding code and \ + references from the codebase into account in order to write high-quality code to \ + complete the code enclosed in \(Tag.openingCode) tags. \ + You only respond with code that works and fits seamlessly with surrounding code. \ + Don't include anything else beyond the code. + + Code completion means to keep writing the code. For example, if I tell you to + ### + Complete code inside \(Tag.openingCode): + + \(Tag.openingCode) + print("Hello + ### + + You should respond with: + ### + World")\(Tag.closingCode) + ### + """.trimmingCharacters(in: .whitespacesAndNewlines) + var sourceRequest: SuggestionRequest + var prefix: [String] + var suffix: [String] + var filePath: String { sourceRequest.relativePath ?? sourceRequest.fileURL.path } + var relevantCodeSnippets: [RelevantCodeSnippet] { sourceRequest.relevantCodeSnippets } + var stopWords: [String] { [Tag.closingCode, "\n\n"] } + var language: CodeLanguage? { sourceRequest.language } + + var suggestionPrefix: SuggestionPrefix { + guard let prefix = prefix.last else { return .empty } + return .unchanged(prefix).curlyBracesLineBreak() + } + + func createPrompt( + truncatedPrefix: [String], + truncatedSuffix: [String], + includedSnippets: [RelevantCodeSnippet] + ) -> [PromptMessage] { + return [.init(role: .user, content: [ + Self.createSnippetsPrompt(includedSnippets: includedSnippets), + createSourcePrompt( + truncatedPrefix: truncatedPrefix, + truncatedSuffix: truncatedSuffix + ), + ].filter { !$0.isEmpty }.joined(separator: "\n\n"))] + } + + func createSourcePrompt(truncatedPrefix: [String], truncatedSuffix: [String]) -> String { + guard let (summary, infillBlock) = Self.createCodeSummary( + truncatedPrefix: truncatedPrefix, + truncatedSuffix: truncatedSuffix, + suggestionPrefix: suggestionPrefix.infillValue + ) else { return "" } + + return """ + Below is the code from file \(filePath) that you are trying to complete. + Review the code carefully, detect the functionality, formats, style, patterns, \ + and logics in use and use them to predict the completion. \ + Make sure your completion has the correct syntax and formatting. \ + Enclose the completion the XML tag \(Tag.openingCode). \ + Don't duplicate existing implementations. \ + + File Path: \(filePath) + Indentation: \ + \(sourceRequest.indentSize) \(sourceRequest.usesTabsForIndentation ? "tab" : "space") + + --- + + Here is the code: + ``` + \(summary) + ``` + + Complete code inside \(Tag.openingCode): + + \(Tag.openingCode)\(infillBlock) + """.trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func createSnippetsPrompt(includedSnippets: [RelevantCodeSnippet]) -> String { + guard !includedSnippets.isEmpty else { return "" } + var content = "References from codebase: \n\n" + for snippet in includedSnippets { + content += """ + \(Tag.openingSnippet) + \(snippet.content) + \(Tag.closingSnippet) + """ + "\n\n" + } + return content.trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func createCodeSummary( + truncatedPrefix: [String], + truncatedSuffix: [String], + suggestionPrefix: String + ) -> (summary: String, infillBlock: String)? { + guard !(truncatedPrefix.isEmpty && truncatedSuffix.isEmpty) else { return nil } + let promptLinesCount = min(10, max(truncatedPrefix.count, 2)) + let prefixLines = truncatedPrefix.prefix( + max(0, truncatedPrefix.count - promptLinesCount) + ) + let promptLines: [String] = { + let proposed = truncatedPrefix.suffix(promptLinesCount) + return Array(proposed.dropLast()) + [suggestionPrefix] + }() + + return ( + summary: "\(prefixLines.joined())\(Tag.openingCode)\(Tag.closingCode)\(truncatedSuffix.joined())", + infillBlock: promptLines.joined() + ) + } + } +} + diff --git a/Tool/Sources/CustomSuggestionService/RequestStrategies/FIMEndpointRequestStrategy.swift b/Tool/Sources/CustomSuggestionService/RequestStrategies/FIMEndpointRequestStrategy.swift new file mode 100644 index 00000000..f8c8c678 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/RequestStrategies/FIMEndpointRequestStrategy.swift @@ -0,0 +1,80 @@ +import CopilotForXcodeKit +import Foundation +import AIModel + +/// A special strategy for FIM endpoints. +struct FIMEndpointRequestStrategy: RequestStrategy { + var sourceRequest: SuggestionRequest + var prefix: [String] + var suffix: [String] + + var shouldSkip: Bool { + prefix.last?.trimmingCharacters(in: .whitespaces) == "}" + } + + func createPrompt() -> Prompt { + Prompt( + sourceRequest: sourceRequest, + prefix: prefix, + suffix: suffix + ) + } + + func createRawSuggestionPostProcessor() -> some RawSuggestionPostProcessingStrategy { + DefaultRawSuggestionPostProcessingStrategy(codeWrappingTags: nil) + } + + func createStreamStopStrategy(model: CustomSuggestionCoordinator.Model) -> some StreamStopStrategy { + FIMStreamStopStrategy(prefix: prefix) + } + + struct Prompt: PromptStrategy { + let systemPrompt: String = "" + var sourceRequest: SuggestionRequest + var prefix: [String] + var suffix: [String] + var filePath: String { sourceRequest.relativePath ?? sourceRequest.fileURL.path } + var relevantCodeSnippets: [RelevantCodeSnippet] { sourceRequest.relevantCodeSnippets } + var stopWords: [String] { [] } + var language: CodeLanguage? { sourceRequest.language } + + var suggestionPrefix: SuggestionPrefix { + guard let prefix = prefix.last else { return .empty } + return .unchanged(prefix) + } + + init(sourceRequest: SuggestionRequest, prefix: [String], suffix: [String]) { + self.sourceRequest = sourceRequest + + let prefix = sourceRequest.relevantCodeSnippets.map { $0.content + "\n\n" } + + prefix + + self.prefix = prefix + self.suffix = suffix + } + + func createPrompt( + truncatedPrefix: [String], + truncatedSuffix: [String], + includedSnippets: [RelevantCodeSnippet] + ) -> [PromptMessage] { + let suffix = truncatedSuffix.joined() + let prefixContent = """ + // File Path: \(filePath) + // Indentation: \ + \(sourceRequest.indentSize) \ + \(sourceRequest.usesTabsForIndentation ? "tab" : "space") + \(includedSnippets.map(\.content).joined(separator: "\n\n")) + \(truncatedPrefix.joined()) + """ + + let suffixContent = suffix.isEmpty ? "\n// End of file" : suffix + + return [ + .init(role: .prefix, content: prefixContent), + .init(role: .suffix, content: suffixContent) + ] + } + } +} + diff --git a/Tool/Sources/CustomSuggestionService/RequestStrategies/FillInTheMiddleRequestStrategy.swift b/Tool/Sources/CustomSuggestionService/RequestStrategies/FillInTheMiddleRequestStrategy.swift new file mode 100644 index 00000000..841e6deb --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/RequestStrategies/FillInTheMiddleRequestStrategy.swift @@ -0,0 +1,150 @@ +import CopilotForXcodeKit +import Foundation +import Preferences +import AIModel + +/// https://ollama.com/library/codellama +struct FillInTheMiddleRequestStrategy: RequestStrategy { + var sourceRequest: SuggestionRequest + var prefix: [String] + var suffix: [String] + + var shouldSkip: Bool { + prefix.last?.trimmingCharacters(in: .whitespaces) == "}" + } + + func createPrompt() -> Prompt { + Prompt( + sourceRequest: sourceRequest, + prefix: prefix, + suffix: suffix + ) + } + + func createStreamStopStrategy(model: CustomSuggestionCoordinator.Model) -> some StreamStopStrategy { + FIMStreamStopStrategy(prefix: prefix) + } + + func createRawSuggestionPostProcessor() -> some RawSuggestionPostProcessingStrategy { + DefaultRawSuggestionPostProcessingStrategy(codeWrappingTags: nil) + } + + enum Tag { + public static var stop: String { UserDefaults.shared.value(for: \.customSuggestionFIMStopToken) } + } + + struct Prompt: PromptStrategy { + fileprivate(set) var systemPrompt: String = "" + var sourceRequest: SuggestionRequest + var prefix: [String] + var suffix: [String] + var filePath: String { sourceRequest.relativePath ?? sourceRequest.fileURL.path } + var relevantCodeSnippets: [RelevantCodeSnippet] { sourceRequest.relevantCodeSnippets } + // Stop words should support multiple comma-separated + var stopWords: [String] { + return ["\n\n"] + Tag.stop.split(separator: ",").map { String($0) }.filter { !$0.isEmpty } + } + var language: CodeLanguage? { sourceRequest.language } + var promptIsRaw: Bool { UserDefaults.shared.value(for: \.customSuggestionFIMPromptIsRaw) } + var attachFileInfo: Bool { UserDefaults.shared.value(for: \.customSuggestionFIMAttachFileInfo) } + + var suggestionPrefix: SuggestionPrefix { + guard let prefix = prefix.last else { return .empty } + return .unchanged(prefix).curlyBracesLineBreak() + } + + func createPrompt( + truncatedPrefix: [String], + truncatedSuffix: [String], + includedSnippets: [RelevantCodeSnippet] + ) -> [PromptMessage] { + let suffix = truncatedSuffix.joined() + var template = UserDefaults.shared.value(for: \.customSuggestionFIMTemplate) + if template.isEmpty { template = UserDefaultPreferenceKeys().customSuggestionFIMTemplate.defaultValue } + // Determine whether to append file information according to attachFileInfo + let fileInfo = attachFileInfo ? """ + // File Path: \(filePath) + // Indentation: \ + \(sourceRequest.indentSize) \ + \(sourceRequest.usesTabsForIndentation ? "tab" : "space") + """ : "" + let prefixContent = """ + \(fileInfo)\(fileInfo.isEmpty ? "" : "\n")\(includedSnippets.map(\.content).joined(separator: "\n\n")) + \(truncatedPrefix.joined()) + """ + + let suffixContent = suffix.isEmpty ? "\n// End of file" : suffix + + return [ + .init( + role: .user, + content: template + .replacingOccurrences(of: "{prefix}", with: prefixContent) + .replacingOccurrences(of: "{suffix}", with: suffixContent) + .trimmingCharacters(in: .whitespacesAndNewlines) + ), + ] + } + } +} + +struct FillInTheMiddleWithSystemPromptRequestStrategy: RequestStrategy { + let strategy: FillInTheMiddleRequestStrategy + + init(sourceRequest: SuggestionRequest, prefix: [String], suffix: [String]) { + strategy = .init(sourceRequest: sourceRequest, prefix: prefix, suffix: suffix) + } + + func createPrompt() -> some PromptStrategy { + var prompt = strategy.createPrompt() + prompt.systemPrompt = """ + You are a senior programer who take the surrounding code and \ + references from the codebase into account in order to write high-quality code to \ + complete the code enclosed in the given code. \ + You only respond with code that works and fits seamlessly with surrounding code. \ + Don't include anything else beyond the code. \ + The prefix will follow the PRE tag and the suffix will follow the SUF tag. \ + You should write the code that fits seamlessly after the MID tag. + """.trimmingCharacters(in: .whitespacesAndNewlines) + + return prompt + } + + func createStreamStopStrategy(model: CustomSuggestionCoordinator.Model) -> some StreamStopStrategy { + strategy.createStreamStopStrategy(model: model) + } + + func createRawSuggestionPostProcessor() -> some RawSuggestionPostProcessingStrategy { + strategy.createRawSuggestionPostProcessor() + } +} + +struct FIMStreamStopStrategy: StreamStopStrategy { + let prefix: [String] + + func shouldStop( + existedLines: [String], + currentLine: String, + proposedLineLimit: Int + ) -> StreamStopStrategyResult { + if let prefixLastLine = prefix.last { + if let lastLineIndex = existedLines.lastIndex(of: prefixLastLine) { + if existedLines.count >= lastLineIndex + 1 + proposedLineLimit { + return .stop(appendingNewContent: true) + } + return .continue + } else { + if existedLines.count >= proposedLineLimit { + return .stop(appendingNewContent: true) + } + return .continue + } + } else { + if existedLines.count >= proposedLineLimit { + return .stop(appendingNewContent: true) + } + return .continue + } + } +} + diff --git a/Tool/Sources/CustomSuggestionService/RequestStrategies/NaiveRequestStrategy.swift b/Tool/Sources/CustomSuggestionService/RequestStrategies/NaiveRequestStrategy.swift new file mode 100644 index 00000000..a448876d --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/RequestStrategies/NaiveRequestStrategy.swift @@ -0,0 +1,86 @@ +import CopilotForXcodeKit +import Foundation +import AIModel + +/// This strategy mixed and rearrange everything naively to make the model think it's writing code +/// at the end of a file. +struct NaiveRequestStrategy: RequestStrategy { + var sourceRequest: SuggestionRequest + var prefix: [String] + var suffix: [String] + + var shouldSkip: Bool { + prefix.last?.trimmingCharacters(in: .whitespaces) == "}" + } + + func createPrompt() -> Request { + Request( + sourceRequest: sourceRequest, + prefix: prefix, + suffix: suffix + ) + } + + func createRawSuggestionPostProcessor() -> some RawSuggestionPostProcessingStrategy { + NoOpRawSuggestionPostProcessingStrategy() + } + + func createStreamStopStrategy(model: CustomSuggestionCoordinator.Model) -> some StreamStopStrategy { + DefaultStreamStopStrategy() + } + + struct Request: PromptStrategy { + let systemPrompt: String = "" + var sourceRequest: SuggestionRequest + var prefix: [String] + var suffix: [String] + var filePath: String { sourceRequest.relativePath ?? sourceRequest.fileURL.path } + var relevantCodeSnippets: [RelevantCodeSnippet] { sourceRequest.relevantCodeSnippets } + var stopWords: [String] { ["\n\n"] } + var language: CodeLanguage? { sourceRequest.language } + + var suggestionPrefix: SuggestionPrefix { + guard let prefix = prefix.last else { return .empty } + return .unchanged(prefix).curlyBracesLineBreak() + } + + func createPrompt( + truncatedPrefix: [String], + truncatedSuffix: [String], + includedSnippets: [RelevantCodeSnippet] + ) -> [PromptMessage] { + let promptLinesCount = min(10, max(truncatedPrefix.count, 2)) + let prefixLines = truncatedPrefix.prefix(truncatedPrefix.count - promptLinesCount) + let promptLines: [String] = { + let proposed = truncatedPrefix.suffix(promptLinesCount) + return Array(proposed.dropLast()) + [suggestionPrefix.infillValue] + }() + + /// Mix and rearrange the file and relevant code snippets. + let code = { + var codes = [String]() + if !includedSnippets.isEmpty { + codes.append(includedSnippets.map(\.content).joined(separator: "\n\n")) + } + if !truncatedSuffix.isEmpty { + codes.append(""" + // From the end of the file + \(truncatedSuffix.joined()) + // End + """) + } + codes.append("\(prefixLines.joined())\(promptLines.joined())") + return codes.joined(separator: "\n\n") + }() + + return [.init(role: .user, content: """ + File path: \(filePath) + + --- + + \(code) + """.trimmingCharacters(in: .whitespacesAndNewlines))] + } + } +} + diff --git a/Tool/Sources/CustomSuggestionService/RequestStrategies/TabbyRequestStrategy.swift b/Tool/Sources/CustomSuggestionService/RequestStrategies/TabbyRequestStrategy.swift new file mode 100644 index 00000000..4b1ab9ab --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/RequestStrategies/TabbyRequestStrategy.swift @@ -0,0 +1,66 @@ +import CopilotForXcodeKit +import Foundation +import AIModel + +/// A special strategy for Tabby. +struct TabbyRequestStrategy: RequestStrategy { + var sourceRequest: SuggestionRequest + var prefix: [String] + var suffix: [String] + + var shouldSkip: Bool { + prefix.last?.trimmingCharacters(in: .whitespaces) == "}" + } + + func createPrompt() -> Prompt { + Prompt( + sourceRequest: sourceRequest, + prefix: prefix, + suffix: suffix + ) + } + + func createRawSuggestionPostProcessor() -> some RawSuggestionPostProcessingStrategy { + NoOpRawSuggestionPostProcessingStrategy() + } + + func createStreamStopStrategy(model: CustomSuggestionCoordinator.Model) -> some StreamStopStrategy { + NeverStreamStopStrategy() + } + + struct Prompt: PromptStrategy { + let systemPrompt: String = "" + var sourceRequest: SuggestionRequest + var prefix: [String] + var suffix: [String] + var filePath: String { sourceRequest.relativePath ?? sourceRequest.fileURL.path } + var relevantCodeSnippets: [RelevantCodeSnippet] { sourceRequest.relevantCodeSnippets } + var stopWords: [String] { [] } + var language: CodeLanguage? { sourceRequest.language } + + var suggestionPrefix: SuggestionPrefix { + guard let prefix = prefix.last else { return .empty } + return .unchanged(prefix) + } + + init(sourceRequest: SuggestionRequest, prefix: [String], suffix: [String]) { + self.sourceRequest = sourceRequest + + let prefix = sourceRequest.relevantCodeSnippets.map { $0.content + "\n\n" } + + prefix + + self.prefix = prefix + self.suffix = suffix + } + + /// Not used by ``TabbyService``. + func createPrompt( + truncatedPrefix: [String], + truncatedSuffix: [String], + includedSnippets: [RelevantCodeSnippet] + ) -> [PromptMessage] { + [] + } + } +} + diff --git a/Tool/Sources/CustomSuggestionService/RequestStrategy.swift b/Tool/Sources/CustomSuggestionService/RequestStrategy.swift new file mode 100644 index 00000000..a66c8b2d --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/RequestStrategy.swift @@ -0,0 +1,83 @@ +import CopilotForXcodeKit +import Foundation +import AIModel +import Parsing + +/// Prompts may behave differently in different LLMs. +/// This protocol allows for different strategies to be used to generate prompts. +protocol RequestStrategy { + associatedtype Prompt: PromptStrategy + associatedtype RawSuggestionPostProcessor: RawSuggestionPostProcessingStrategy + associatedtype SomeStreamStopStrategy: StreamStopStrategy + + init(sourceRequest: SuggestionRequest, prefix: [String], suffix: [String]) + + /// If the request should be skipped. + var shouldSkip: Bool { get } + + /// Create a prompt to generate code completion. + func createPrompt() -> Prompt + + /// Control how a stream should stop early. + func createStreamStopStrategy(model: CustomSuggestionCoordinator.Model) -> SomeStreamStopStrategy + + /// The AI model may not return a suggestion in a ideal format. You can use it to reformat the + /// suggestions. + func createRawSuggestionPostProcessor() -> RawSuggestionPostProcessor +} + +public enum RequestStrategyOption: String, CaseIterable, Codable { + case `default` = "" + case naive + case `continue` + case codeLlamaFillInTheMiddle + case codeLlamaFillInTheMiddleWithSystemPrompt + case anthropic +} + +extension RequestStrategyOption { + var strategy: any RequestStrategy.Type { + switch self { + case .default: + return DefaultRequestStrategy.self + case .naive: + return NaiveRequestStrategy.self + case .continue: + return ContinueRequestStrategy.self + case .codeLlamaFillInTheMiddle: + return FillInTheMiddleRequestStrategy.self + case .codeLlamaFillInTheMiddleWithSystemPrompt: + return FillInTheMiddleWithSystemPromptRequestStrategy.self + case .anthropic: + return AnthropicRequestStrategy.self + } + } +} + +// MARK: - Default Implementations + +extension RequestStrategy { + var shouldSkip: Bool { false } +} + +// MARK: - Suggestion Prefix Helpers + +extension SuggestionPrefix { + func curlyBracesLineBreak() -> SuggestionPrefix { + func mutate(_ string: String) -> String { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasSuffix("{") { + return string + " " + } + if trimmed.hasSuffix("}") { + return string + "\n" + } + return string + } + + let infillValue = mutate(infillValue) + let prependingValue = mutate(prependingValue) + return .init(original: original, infillValue: infillValue, prependingValue: prependingValue) + } +} + diff --git a/Tool/Sources/CustomSuggestionService/ResponseStream.swift b/Tool/Sources/CustomSuggestionService/ResponseStream.swift new file mode 100644 index 00000000..ce28b7f9 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/ResponseStream.swift @@ -0,0 +1,45 @@ +import Foundation + +struct ResponseStream: AsyncSequence { + func makeAsyncIterator() -> Stream.AsyncIterator { + stream.makeAsyncIterator() + } + + typealias Stream = AsyncThrowingStream + typealias AsyncIterator = Stream.AsyncIterator + typealias Element = Chunk + + struct LineContent { + let chunk: Chunk? + let done: Bool + } + + let stream: Stream + + init(result: URLSession.AsyncBytes, lineExtractor: @escaping (String) throws -> LineContent) { + stream = AsyncThrowingStream { continuation in + let task = Task { + do { + for try await line in result.lines { + if Task.isCancelled { break } + let content = try lineExtractor(line) + if let chunk = content.chunk { + continuation.yield(chunk) + } + + if content.done { break } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + result.task.cancel() + } + } + continuation.onTermination = { _ in + task.cancel() + result.task.cancel() + } + } + } +} + diff --git a/Tool/Sources/CustomSuggestionService/StreamLineLimiter.swift b/Tool/Sources/CustomSuggestionService/StreamLineLimiter.swift new file mode 100644 index 00000000..2f65f871 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/StreamLineLimiter.swift @@ -0,0 +1,63 @@ +import Foundation +import Preferences + +final class StreamLineLimiter { + public private(set) var result = "" + private var currentLine = "" + private var existedLines = [String]() + private let lineLimit: Int + private let strategy: any StreamStopStrategy + + enum PushResult: Equatable { + case `continue` + case finish(String) + } + + init( + lineLimit: Int = UserDefaults.shared.value(for: \.customSuggestionMaxLines), + strategy: any StreamStopStrategy + ) { + self.lineLimit = lineLimit + self.strategy = strategy + } + + func push(_ token: String) -> PushResult { + currentLine.append(token) + if let newLine = currentLine.last(where: { $0.isNewline }) { + let lines = currentLine + .breakLines(proposedLineEnding: String(newLine), appendLineBreakToLastLine: false) + let (newLines, lastLine) = lines.headAndTail + existedLines.append(contentsOf: newLines) + currentLine = lastLine ?? "" + } + + let stopResult = if lineLimit <= 0 { + StreamStopStrategyResult.continue + } else { + strategy.shouldStop( + existedLines: existedLines, + currentLine: currentLine, + proposedLineLimit: lineLimit + ) + } + + switch stopResult { + case .continue: + result.append(token) + return .continue + case let .stop(appendingNewContent): + if appendingNewContent { + result.append(token) + } + return .finish(result) + } + } +} + +extension Array { + var headAndTail: ([Element], Element?) { + guard let tail = last else { return ([], nil) } + return (Array(dropLast()), tail) + } +} + diff --git a/Tool/Sources/CustomSuggestionService/StreamStopStrategy/DefaultStreamStopStrategy.swift b/Tool/Sources/CustomSuggestionService/StreamStopStrategy/DefaultStreamStopStrategy.swift new file mode 100644 index 00000000..8e9c3592 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/StreamStopStrategy/DefaultStreamStopStrategy.swift @@ -0,0 +1,15 @@ +public struct DefaultStreamStopStrategy: StreamStopStrategy { + public init() {} + + public func shouldStop( + existedLines: [String], + currentLine: String, + proposedLineLimit: Int + ) -> StreamStopStrategyResult { + if existedLines.count >= proposedLineLimit { + return .stop(appendingNewContent: true) + } + return .continue + } +} + diff --git a/Tool/Sources/CustomSuggestionService/StreamStopStrategy/NeverStreamStopStrategy.swift b/Tool/Sources/CustomSuggestionService/StreamStopStrategy/NeverStreamStopStrategy.swift new file mode 100644 index 00000000..fa924dd6 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/StreamStopStrategy/NeverStreamStopStrategy.swift @@ -0,0 +1,12 @@ +public struct NeverStreamStopStrategy: StreamStopStrategy { + public init() {} + + public func shouldStop( + existedLines: [String], + currentLine: String, + proposedLineLimit: Int + ) -> StreamStopStrategyResult { + .continue + } +} + diff --git a/Tool/Sources/CustomSuggestionService/StreamStopStrategy/OpeningTagBasedStreamStopStrategy.swift b/Tool/Sources/CustomSuggestionService/StreamStopStrategy/OpeningTagBasedStreamStopStrategy.swift new file mode 100644 index 00000000..02cd3819 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/StreamStopStrategy/OpeningTagBasedStreamStopStrategy.swift @@ -0,0 +1,31 @@ +import Foundation + +public struct OpeningTagBasedStreamStopStrategy: StreamStopStrategy { + public let openingTag: String + public let toleranceIfNoOpeningTagFound: Int + + public init(openingTag: String, toleranceIfNoOpeningTagFound: Int) { + self.openingTag = openingTag + self.toleranceIfNoOpeningTagFound = toleranceIfNoOpeningTagFound + } + + public func shouldStop( + existedLines: [String], + currentLine: String, + proposedLineLimit: Int + ) -> StreamStopStrategyResult { + if let index = existedLines.firstIndex(where: { $0.contains(openingTag) }) { + if existedLines.count - index - 1 >= proposedLineLimit { + return .stop(appendingNewContent: true) + } + return .continue + } else { + if existedLines.count >= proposedLineLimit + toleranceIfNoOpeningTagFound { + return .stop(appendingNewContent: true) + } else { + return .continue + } + } + } +} + diff --git a/Tool/Sources/CustomSuggestionService/StreamStopStrategy/StreamStopStrategy.swift b/Tool/Sources/CustomSuggestionService/StreamStopStrategy/StreamStopStrategy.swift new file mode 100644 index 00000000..b493d55d --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/StreamStopStrategy/StreamStopStrategy.swift @@ -0,0 +1,12 @@ +import Foundation + +public enum StreamStopStrategyResult { + case `continue` + case stop(appendingNewContent: Bool) +} + +public protocol StreamStopStrategy { + func shouldStop(existedLines: [String], currentLine: String, proposedLineLimit: Int) + -> StreamStopStrategyResult +} + diff --git a/Tool/Sources/CustomSuggestionService/TextProcessing/ConvertRange.swift b/Tool/Sources/CustomSuggestionService/TextProcessing/ConvertRange.swift new file mode 100644 index 00000000..7b41b073 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/TextProcessing/ConvertRange.swift @@ -0,0 +1,34 @@ +import CopilotForXcodeKit +import Foundation + +public func convertRangeToCursorRange( + _ range: ClosedRange, + in lines: [String] +) -> CursorRange { + guard !lines.isEmpty else { return CursorRange(start: .zero, end: .zero) } + var countS = 0 + var countE = 0 + var cursorRange = CursorRange(start: .zero, end: .outOfScope) + for (i, line) in lines.enumerated() { + // The range is counted in UTF8, which causes line endings like \r\n to be of length 2. + let lineEndingAddition = line.lineEnding.utf8.count - 1 + if countS <= range.lowerBound, + range.lowerBound < countS + line.count + lineEndingAddition + { + cursorRange.start = .init(line: i, character: range.lowerBound - countS) + } + if countE <= range.upperBound, + range.upperBound < countE + line.count + lineEndingAddition + { + cursorRange.end = .init(line: i, character: range.upperBound - countE) + break + } + countS += line.count + lineEndingAddition + countE += line.count + lineEndingAddition + } + if cursorRange.end == .outOfScope { + cursorRange.end = .init(line: lines.endIndex - 1, character: lines.last?.count ?? 0) + } + return cursorRange +} + diff --git a/Tool/Sources/CustomSuggestionService/TextProcessing/String+Extensions.swift b/Tool/Sources/CustomSuggestionService/TextProcessing/String+Extensions.swift new file mode 100644 index 00000000..ddbe9903 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/TextProcessing/String+Extensions.swift @@ -0,0 +1,52 @@ +import Foundation + +public extension String { + /// The line ending of the string. + /// + /// We are pretty safe to just check the last character here, in most case, a line ending + /// will be in the end of the string. + /// + /// For other situations, we can assume that they are "\n". + var lineEnding: Character { + if let last, last.isNewline { return last } + return "\n" + } + + func splitByNewLine( + omittingEmptySubsequences: Bool = true, + fast: Bool = true + ) -> [Substring] { + if fast { + let lineEndingInText = lineEnding + return split( + separator: lineEndingInText, + omittingEmptySubsequences: omittingEmptySubsequences + ) + } + return split( + omittingEmptySubsequences: omittingEmptySubsequences, + whereSeparator: \.isNewline + ) + } + + /// Break a string into lines. + func breakLines( + proposedLineEnding: String? = nil, + appendLineBreakToLastLine: Bool = false + ) -> [String] { + let lineEndingInText = lineEnding + let lineEnding = proposedLineEnding ?? String(lineEndingInText) + // Split on character for better performance. + let lines = split(separator: lineEndingInText, omittingEmptySubsequences: false) + var all = [String]() + for (index, line) in lines.enumerated() { + if !appendLineBreakToLastLine, index == lines.endIndex - 1 { + all.append(String(line)) + } else { + all.append(String(line) + lineEnding) + } + } + return all + } +} + diff --git a/Tool/Sources/CustomSuggestionService/TruncateStrategy/TruncateStrategy.swift b/Tool/Sources/CustomSuggestionService/TruncateStrategy/TruncateStrategy.swift new file mode 100644 index 00000000..a418b720 --- /dev/null +++ b/Tool/Sources/CustomSuggestionService/TruncateStrategy/TruncateStrategy.swift @@ -0,0 +1,91 @@ +import CopilotForXcodeKit +import Foundation + +public protocol TruncateStrategy { + func createTruncatedPrompt(promptStrategy: PromptStrategy) -> [PromptMessage] +} + +public struct DefaultTruncateStrategy: TruncateStrategy { + let maxTokenLimit: Int + let countToken: ([PromptMessage]) -> Int + + public init(maxTokenLimit: Int, countToken: @escaping ([PromptMessage]) -> Int = { + $0.reduce(0) { $0 + $1.content.count } + }) { + self.maxTokenLimit = maxTokenLimit + self.countToken = countToken + } + + public func createTruncatedPrompt(promptStrategy: PromptStrategy) -> [PromptMessage] { + var prefix = promptStrategy.prefix + var suffix = promptStrategy.suffix + var snippets = promptStrategy.relevantCodeSnippets + + var prompts = promptStrategy.createPrompt( + truncatedPrefix: prefix, + truncatedSuffix: suffix, + includedSnippets: snippets + ) + + let limit = maxTokenLimit - countToken([.init( + role: .user, + content: promptStrategy.systemPrompt + )]) + + let prefixDropWeight = 1 + let suffixDropWeight = 5 + let snippetsDropWeight = 8 + + while countToken(prompts) > limit, + !(prefix.isEmpty && suffix.isEmpty && snippets.isEmpty) + { + let p = prefix.count * prefixDropWeight + let s = suffix.count * suffixDropWeight + let n = snippets.count * snippetsDropWeight + + let maxScore = max(p, s, n) + switch maxScore { + case s: + truncateSuffix(&suffix) + case n: + truncateSnippets(&snippets) + case p: + truncatePrefix(&prefix) + default: + truncateSuffix(&suffix) + } + + prompts = promptStrategy.createPrompt( + truncatedPrefix: prefix, + truncatedSuffix: suffix, + includedSnippets: snippets + ) + } + + return prompts + } + + /// Drop the last one third. + func truncateSuffix(_ suffix: inout [String]) { + let step = 3 + + if suffix.isEmpty { return } + let dropCount = max(suffix.count / step, 1) + suffix.removeLast(dropCount) + } + + /// Drop the leading one fourth. + func truncatePrefix(_ prefix: inout [String]) { + let step = 4 + + if prefix.isEmpty { return } + let dropCount = max(prefix.count / step, 1) + prefix.removeFirst(dropCount) + } + + func truncateSnippets(_ snippets: inout [RelevantCodeSnippet]) { + if snippets.isEmpty { return } + snippets.removeLast() + } +} + diff --git a/Tool/Sources/Preferences/CustomSuggestionServiceKeys.swift b/Tool/Sources/Preferences/CustomSuggestionServiceKeys.swift new file mode 100644 index 00000000..a042b638 --- /dev/null +++ b/Tool/Sources/Preferences/CustomSuggestionServiceKeys.swift @@ -0,0 +1,85 @@ +import AIModel +import Foundation + +/// Preference keys of the built-in custom model suggestion provider. +/// +/// The raw key strings keep the `CustomSuggestionService-` prefix used by the original standalone +/// "Custom Suggestion Service for Copilot for Xcode" app. +public extension UserDefaultPreferenceKeys { + /// Either a `CustomModelType` raw value, or the id of a chat model in ``chatModels``. + var customSuggestionModelId: PreferenceKey { + .init( + defaultValue: CustomModelType.default.rawValue, + key: "CustomSuggestionService-SuggestionChatModelId" + ) + } + + + var customSuggestionCompletionModel: PreferenceKey> { + .init( + defaultValue: .init(CompletionModel( + id: "ID", + name: "Custom", + format: .openAI, + info: .init() + )), + key: "CustomSuggestionService-CustomCompletionModel" + ) + } + + var customSuggestionFIMModel: PreferenceKey> { + .init( + defaultValue: .init(FIMModel(id: "ID", name: "Custom", format: .mistral, info: .init())), + key: "CustomSuggestionService-CustomFIMModel" + ) + } + + var customSuggestionTabbyModel: PreferenceKey> { + .init( + defaultValue: .init(.init( + url: "", + authorizationMode: .none, + apiKeyName: "", + authorizationHeaderName: "", + username: "" + )), + key: "CustomSuggestionService-TabbyModel" + ) + } + + var customSuggestionRequestStrategyId: PreferenceKey { + .init(defaultValue: "", key: "CustomSuggestionService-RequestStrategyId") + } + + /// 0 means unlimited. + var customSuggestionMaxLines: PreferenceKey { + .init(defaultValue: 0, key: "CustomSuggestionService-MaxNumberOfLinesOfSuggestion") + } + + var customSuggestionMaxGenerationToken: PreferenceKey { + .init(defaultValue: 200, key: "CustomSuggestionService-MaxGenerationToken") + } + + var customSuggestionVerboseLog: PreferenceKey { + .init(defaultValue: false, key: "CustomSuggestionService-VerboseLog") + } + + var customSuggestionFIMStopToken: PreferenceKey { + .init(defaultValue: "", key: "CustomSuggestionService-FimStopToken") + } + + var customSuggestionFIMTemplate: PreferenceKey { + .init( + defaultValue: "
 {prefix} {suffix} ",
+            key: "CustomSuggestionService-FimTemplate"
+        )
+    }
+
+    var customSuggestionFIMPromptIsRaw: PreferenceKey {
+        .init(defaultValue: false, key: "CustomSuggestionService-FimPromptIsRaw")
+    }
+
+    var customSuggestionFIMAttachFileInfo: PreferenceKey {
+        .init(defaultValue: true, key: "CustomSuggestionService-fimAttachFileInfo")
+    }
+}
diff --git a/Tool/Sources/Preferences/Types/SuggestionFeatureProvider.swift b/Tool/Sources/Preferences/Types/SuggestionFeatureProvider.swift
index b6f2eeb5..28b8c01a 100644
--- a/Tool/Sources/Preferences/Types/SuggestionFeatureProvider.swift
+++ b/Tool/Sources/Preferences/Types/SuggestionFeatureProvider.swift
@@ -3,6 +3,7 @@ import Foundation
 public enum BuiltInSuggestionFeatureProvider: Int, CaseIterable, Codable {
     case gitHubCopilot
     case codeium
+    case customModel
 }
 
 public enum SuggestionFeatureProvider: RawRepresentable, Hashable {
diff --git a/Tool/Tests/CustomSuggestionServiceTests/CodeSplitAtCursorTests.swift b/Tool/Tests/CustomSuggestionServiceTests/CodeSplitAtCursorTests.swift
new file mode 100644
index 00000000..e62edb2b
--- /dev/null
+++ b/Tool/Tests/CustomSuggestionServiceTests/CodeSplitAtCursorTests.swift
@@ -0,0 +1,100 @@
+import Foundation
+import AIModel
+import XCTest
+
+@testable import CustomSuggestionService
+
+class CodeSplitAtCursorTests: XCTestCase {
+    func test_split_at_the_end_of_a_file() {
+        let code = """
+        func mergeSort(_ array: [T]) -> [T] {
+            guard array.count > 1 else { return array }
+            let middle = array.count / 2
+            let left = mergeSort(Array(array[..(_ array: [T]) -> [T] {
+            guard array.count > 1 else { return array }
+            let middle = array.count / 2
+            let left = mergeSort(Array(array[..(_ array: [T]) -> [T] {
+            guard array.count > 1 else { return array }
+            let middle = array.count / 2
+            let left = mergeSort(Array(array[..(_ array: [T]) -> [T] {
+            guard array.count > 1 else { return array }
+            let middle = array.count / 2
+            let left = mergeSort(Array(array[..", "")
+        )
+        let result = strategy.extractSuggestion(
+            from: """
+            suggestion
+            """
+        )
+
+        XCTAssertEqual(result, "suggestion")
+    }
+
+    func test_whenSuggestionHasCodeTagAtTheFirstLine_closingTagInOtherLines_shouldExtractCodeInside(
+    ) {
+        let strategy = DefaultRawSuggestionPostProcessingStrategy(
+            codeWrappingTags: ("", "")
+        )
+        let result = strategy.extractSuggestion(
+            from: """
+            suggestion
+            yes
+            """
+        )
+
+        XCTAssertEqual(result, "suggestion\nyes")
+    }
+
+    func test_whenSuggestionHasCodeTag_butNoClosingTag_shouldExtractCodeAfterTheTag() {
+        let strategy = DefaultRawSuggestionPostProcessingStrategy(
+            codeWrappingTags: ("", "")
+        )
+        let result = strategy.extractSuggestion(
+            from: """
+            suggestion
+            yes
+            """
+        )
+
+        XCTAssertEqual(result, "suggestion\nyes")
+    }
+
+    func test_whenMultipleOpeningTagFound_shouldTreatTheNextOneAsClosing() {
+        let strategy = DefaultRawSuggestionPostProcessingStrategy(
+            codeWrappingTags: ("", "")
+        )
+        let result = strategy.extractSuggestion(
+            from: """
+            suggestionhello
+            """
+        )
+        XCTAssertEqual(result, "suggestion")
+    }
+
+    func test_whenMarkdownCodeBlockFound_shouldExtractCodeInside() {
+        let strategy = DefaultRawSuggestionPostProcessingStrategy(
+            codeWrappingTags: ("", "")
+        )
+        let result = strategy.extractSuggestion(
+            from: """
+            ```language
+            suggestion
+            ```
+            """
+        )
+
+        XCTAssertEqual(result, "suggestion\n")
+    }
+
+    func test_whenOnlyLinebreaksOrSpacesBeforeMarkdownCodeBlock_shouldExtractCodeInside() {
+        let strategy = DefaultRawSuggestionPostProcessingStrategy(
+            codeWrappingTags: ("", "")
+        )
+        let result = strategy.extractSuggestion(
+            from: """
+
+
+                 ```
+            suggestion
+            ```
+            """
+        )
+
+        XCTAssertEqual(result, "suggestion\n")
+
+        let result2 = strategy.extractSuggestion(
+            from: """
+                    ```
+            suggestion
+            ```
+            """
+        )
+
+        XCTAssertEqual(result2, "suggestion\n")
+
+        let result3 = strategy.extractSuggestion(
+            from: """
+
+
+            ```
+            suggestion
+            ```
+            """
+        )
+
+        XCTAssertEqual(result3, "suggestion\n")
+    }
+
+    func test_whenMarkdownCodeBlockAndCodeTagFound_firstlyExtractCodeTag_thenCodeTag() {
+        let strategy = DefaultRawSuggestionPostProcessingStrategy(
+            codeWrappingTags: ("", "")
+        )
+        let result = strategy.extractSuggestion(
+            from: """
+            ```language
+            suggestion
+            suggestion
+            ```
+            """
+        )
+        XCTAssertEqual(result, "suggestion")
+    }
+
+    func test_whenMarkdownCodeBlockAndCodeTagFound_butNoClosingTag_firstlyExtractCodeTag_thenCodeTag(
+    ) {
+        let strategy = DefaultRawSuggestionPostProcessingStrategy(
+            codeWrappingTags: ("", "")
+        )
+        let result = strategy.extractSuggestion(
+            from: """
+            ```language
+            suggestion
+            suggestion
+            ```
+            """
+        )
+        XCTAssertEqual(result, "suggestion\nsuggestion\n")
+    }
+
+    func test_whenSuggestionHasTheSamePrefix_removeThePrefix() {
+        let strategy = DefaultRawSuggestionPostProcessingStrategy(
+            codeWrappingTags: ("", "")
+        )
+        let result = strategy.extractSuggestion(
+            from: "suggestion"
+        )
+
+        XCTAssertEqual(result, "suggestion")
+    }
+
+    func test_whenSuggestionLooksLikeAMessage_parseItCorrectly() {
+        let strategy = DefaultRawSuggestionPostProcessingStrategy(
+            codeWrappingTags: ("", "")
+        )
+        let result = strategy.extractSuggestion(
+            from: """
+            Here is the suggestion:
+            ```language
+            suggestion
+            ```
+            """
+        )
+
+        XCTAssertEqual(result, "suggestion\n")
+    }
+
+    func test_whenSuggestionHasTheSamePrefix_inTags_removeThePrefix() {
+        let strategy = DefaultRawSuggestionPostProcessingStrategy(
+            codeWrappingTags: ("", "")
+        )
+        var suggestion = "prefix suggestion"
+        strategy.removePrefix(from: &suggestion, infillPrefix: "prefix")
+
+        XCTAssertEqual(suggestion, " suggestion")
+    }
+
+    func test_whenSuggestionHasTheSameSuffix_removeTheSuffix() {
+        let strategy = DefaultRawSuggestionPostProcessingStrategy(
+            codeWrappingTags: ("", "")
+        )
+        var suggestion = "suggestion\na\nb"
+        strategy.removeSuffix(from: &suggestion, suffix: [
+            "a\n",
+            "b\n",
+        ])
+
+        XCTAssertEqual(suggestion, "suggestion\n")
+
+        var suggestion2 = "suggestion\na\nb"
+        strategy.removeSuffix(from: &suggestion2, suffix: [])
+
+        XCTAssertEqual(suggestion2, "suggestion\na\nb")
+
+        var suggestion3 = "suggestion\na\nb"
+        strategy.removeSuffix(from: &suggestion3, suffix: ["b\n"])
+
+        XCTAssertEqual(suggestion3, "suggestion\na\n")
+    }
+
+    func test_case_1() {
+        let strategy = DefaultRawSuggestionPostProcessingStrategy(
+            codeWrappingTags: ("", "")
+        )
+        let result = strategy.postProcess(
+            rawSuggestion: """
+            ```language
+            prefix suggestion
+            a
+            b
+            c
+            ```
+            """,
+            infillPrefix: "prefix",
+            suffix: ["a\n", "b\n", "c\n"]
+        )
+        XCTAssertEqual(result, "prefix suggestion")
+    }
+}
+
diff --git a/Tool/Tests/CustomSuggestionServiceTests/DefaultRequestStrategyTests.swift b/Tool/Tests/CustomSuggestionServiceTests/DefaultRequestStrategyTests.swift
new file mode 100644
index 00000000..58d8ce39
--- /dev/null
+++ b/Tool/Tests/CustomSuggestionServiceTests/DefaultRequestStrategyTests.swift
@@ -0,0 +1,126 @@
+import Foundation
+import AIModel
+import XCTest
+
+@testable import CustomSuggestionService
+
+class DefaultRequestStrategyTests: XCTestCase {
+    func test_source_prompt_creation_empty_suffix() {
+        let prefix = """
+        print("1")
+        print("2")
+        print("3")
+        print("4")
+        print("5")
+        print("6")
+        print("7")
+        print("8")
+        print("9")
+        print("10")
+        print("11")
+        print("12")
+        print("13")
+        print("14")
+        print("15")
+        let cat:
+        """
+
+        guard let (summary, infillBlock) = DefaultRequestStrategy.Prompt.createCodeSummary(
+            truncatedPrefix: prefix.breakLines(),
+            truncatedSuffix: [], 
+            suggestionPrefix: "let cat:"
+        ) else {
+            XCTFail()
+            return
+        }
+
+        XCTAssertEqual(summary, """
+        print("1")
+        print("2")
+        print("3")
+        print("4")
+        print("5")
+        print("6")
+        
+        """)
+        XCTAssertEqual(infillBlock, """
+        print("7")
+        print("8")
+        print("9")
+        print("10")
+        print("11")
+        print("12")
+        print("13")
+        print("14")
+        print("15")
+        let cat:
+        """, "At most 10 lines")
+    }
+
+    func test_source_prompt_creation_has_suffix() {
+        let prefix = """
+        print("1")
+        print("2")
+        print("3")
+        print("4")
+        print("5")
+        print("6")
+        print("7")
+        print("8")
+        print("9")
+        print("10")
+        print("11")
+        print("12")
+        print("13")
+        print("14")
+        print("15")
+        let cat:
+        """
+        
+        let suffix = """
+        
+        print("1")
+        print("2")
+        print("3")
+        print("4")
+        print("5")
+        """
+
+        guard let (summary, infillBlock) = DefaultRequestStrategy.Prompt.createCodeSummary(
+            truncatedPrefix: prefix.breakLines(),
+            truncatedSuffix: suffix.breakLines(),
+            suggestionPrefix: "let cat:"
+        ) else {
+            XCTFail()
+            return
+        }
+
+        XCTAssertEqual(summary, """
+        print("1")
+        print("2")
+        print("3")
+        print("4")
+        print("5")
+        print("6")
+        
+        print("1")
+        print("2")
+        print("3")
+        print("4")
+        print("5")
+        """)
+        XCTAssertEqual(infillBlock, """
+        print("7")
+        print("8")
+        print("9")
+        print("10")
+        print("11")
+        print("12")
+        print("13")
+        print("14")
+        print("15")
+        let cat:
+        """, "At most 10 lines")
+    }
+}
+
diff --git a/Tool/Tests/CustomSuggestionServiceTests/OpeningTagBasedStreamStopStrategyTests.swift b/Tool/Tests/CustomSuggestionServiceTests/OpeningTagBasedStreamStopStrategyTests.swift
new file mode 100644
index 00000000..3dd725c4
--- /dev/null
+++ b/Tool/Tests/CustomSuggestionServiceTests/OpeningTagBasedStreamStopStrategyTests.swift
@@ -0,0 +1,106 @@
+import Foundation
+import XCTest
+
+@testable import CustomSuggestionService
+
+class OpeningTagBasedStreamStopStrategyTests: XCTestCase {
+    func test_no_opening_tag_found_and_not_hitting_limit() {
+        let strategy = OpeningTagBasedStreamStopStrategy(
+            openingTag: "",
+            toleranceIfNoOpeningTagFound: 3
+        )
+        let limiter = StreamLineLimiter(lineLimit: 1, strategy: strategy)
+        let content = """
+        Hello World
+        My Friend
+        """
+        for character in content {
+            let result = limiter.push(String(character))
+            XCTAssertEqual(result, .continue)
+        }
+        XCTAssertEqual(limiter.result, content)
+    }
+    
+    func test_no_opening_tag_found_hitting_limit() {
+        let strategy = OpeningTagBasedStreamStopStrategy(
+            openingTag: "",
+            toleranceIfNoOpeningTagFound: 3
+        )
+        let limiter = StreamLineLimiter(lineLimit: 1, strategy: strategy)
+        let content = """
+        Hello World
+        My Friend
+        How Are You
+        I Am Fine
+        Thank You
+        """
+        
+        let expected = """
+        Hello World
+        My Friend
+        How Are You
+        I Am Fine
+        
+        """
+        
+        for character in content {
+            let result = limiter.push(String(character))
+            if result == .finish(expected) {
+                XCTAssertEqual(limiter.result, expected)
+                return
+            }
+        }
+        XCTFail("Should return in the loop\n\n\(limiter.result)")
+    }
+    
+    func test_opening_tag_found_not_hitting_limit() {
+        let strategy = OpeningTagBasedStreamStopStrategy(
+            openingTag: "",
+            toleranceIfNoOpeningTagFound: 3
+        )
+        let limiter = StreamLineLimiter(lineLimit: 2, strategy: strategy)
+        let content = """
+        Hello World
+        
+        How Are You
+        """
+        for character in content {
+            let result = limiter.push(String(character))
+            XCTAssertEqual(result, .continue)
+        }
+        XCTAssertEqual(limiter.result, content)
+    }
+    
+    func test_opening_tag_found_hitting_limit() {
+        let strategy = OpeningTagBasedStreamStopStrategy(
+            openingTag: "",
+            toleranceIfNoOpeningTagFound: 3
+        )
+        let limiter = StreamLineLimiter(lineLimit: 2, strategy: strategy)
+        let content = """
+        Hello World
+        
+        How Are You
+        I Am Fine
+        Thank You
+        """
+        
+        let expected = """
+        Hello World
+        
+        How Are You
+        I Am Fine
+        
+        """
+        
+        for character in content {
+            let result = limiter.push(String(character))
+            if result == .finish(expected) {
+                XCTAssertEqual(limiter.result, expected)
+                return
+            }
+        }
+        XCTFail("Should return in the loop\n\n\(limiter.result)")
+    }
+}
+
diff --git a/Tool/Tests/CustomSuggestionServiceTests/StreamLineLimiterTests.swift b/Tool/Tests/CustomSuggestionServiceTests/StreamLineLimiterTests.swift
new file mode 100644
index 00000000..5549adc8
--- /dev/null
+++ b/Tool/Tests/CustomSuggestionServiceTests/StreamLineLimiterTests.swift
@@ -0,0 +1,69 @@
+import Foundation
+import XCTest
+
+@testable import CustomSuggestionService
+
+class StreamLineLimiterTests: XCTestCase {
+    func test_pushing_characters_without_hitting_limit() {
+        let limiter = StreamLineLimiter(lineLimit: 2, strategy: DefaultStreamStopStrategy())
+        let content = "hello world\n"
+        for character in content {
+            let result = limiter.push(String(character))
+            XCTAssertEqual(result, .continue)
+        }
+        XCTAssertEqual(limiter.result, content)
+    }
+
+    func test_pushing_characters_hitting_limit() {
+        let limiter = StreamLineLimiter(lineLimit: 2, strategy: DefaultStreamStopStrategy())
+        let content = "hello world\nhello world\nhello world"
+        for character in content {
+            let result = limiter.push(String(character))
+            if result == .finish("hello world\nhello world\n") {
+                XCTAssertEqual(limiter.result, "hello world\nhello world\n")
+                return
+            }
+        }
+        XCTFail("Should return in the loop\n\(limiter.result)")
+    }
+
+    func test_pushing_characters_with_early_exit_strategy() {
+        struct Strategy: StreamStopStrategy {
+            func shouldStop(
+                existedLines: [String],
+                currentLine: String,
+                proposedLineLimit: Int
+            ) -> StreamStopStrategyResult {
+                let hasPrefixP = currentLine.hasPrefix("p")
+                let hasNewLine = existedLines.first?.hasSuffix("\n") ?? false
+                if hasPrefixP && hasNewLine {
+                    return .stop(appendingNewContent: false)
+                }
+                return .continue
+            }
+        }
+
+        let limiter = StreamLineLimiter(lineLimit: 10, strategy: Strategy())
+        let content = "hello world\npikachu\n"
+        for character in content {
+            let result = limiter.push(String(character))
+            if result == .finish("hello world\n") {
+                XCTAssertEqual(limiter.result, "hello world\n")
+                return
+            }
+        }
+        XCTFail("Should return in the loop\n\(limiter.result)")
+    }
+
+    func test_receiving_multiple_line_ending_as_a_single_token() {
+        let limiter = StreamLineLimiter(lineLimit: 4, strategy: DefaultStreamStopStrategy())
+        let content = "hello world"
+        for character in content {
+            let result = limiter.push(String(character))
+            XCTAssertEqual(result, .continue)
+        }
+        XCTAssertEqual(limiter.push("\n\n\n"), .continue)
+        XCTAssertEqual(limiter.push("\n"), .finish("hello world\n\n\n\n"))
+    }
+}
+

From 632ce6f2274931bfbe43f255843fb63678793b4a Mon Sep 17 00:00:00 2001
From: Skyler Liu <9743878+JayDaniel@users.noreply.github.com>
Date: Mon, 7 Sep 2026 01:50:55 -0700
Subject: [PATCH 03/16] Add a settings page for custom models

- Replace the Other Suggestion Services promo page in the Service
  sidebar with a Custom Model settings page
- Let the user pick a host chat model, or a custom Completion, FIM or
  Tabby model, and edit it in place
- Expose the request strategy, the FIM template, the line and token
  limits, and the verbose log switch
- Give APIKeyPicker an optional title

Claude-Session: https://claude.ai/code/session_01QrUJLx9ppwpa2DFUoSGUYo
---
 Core/Package.swift                            |   1 +
 .../APIKeyManagement/APIKeyPicker.swift       |   3 +-
 .../CompletionModelEdit.swift                 | 156 +++++++++
 .../CompletionModelEditView.swift             | 324 ++++++++++++++++++
 .../CustomModelSuggestion.swift               |  35 ++
 .../CustomModelSuggestionView.swift           | 216 ++++++++++++
 .../CustomModelSuggestion/FIMModelEdit.swift  | 159 +++++++++
 .../FIMModelEditView.swift                    | 313 +++++++++++++++++
 .../TabbyModelEdit.swift                      | 104 ++++++
 .../TabbyModelEditView.swift                  | 167 +++++++++
 .../OtherSuggestionServicesView.swift         |  31 --
 Core/Sources/HostApp/HostApp.swift            |   9 +
 Core/Sources/HostApp/ServiceView.swift        |   7 +-
 13 files changed, 1491 insertions(+), 34 deletions(-)
 create mode 100644 Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CompletionModelEdit.swift
 create mode 100644 Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CompletionModelEditView.swift
 create mode 100644 Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestion.swift
 create mode 100644 Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift
 create mode 100644 Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/FIMModelEdit.swift
 create mode 100644 Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/FIMModelEditView.swift
 create mode 100644 Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/TabbyModelEdit.swift
 create mode 100644 Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/TabbyModelEditView.swift
 delete mode 100644 Core/Sources/HostApp/AccountSettings/OtherSuggestionServicesView.swift

diff --git a/Core/Package.swift b/Core/Package.swift
index fb1fdcb9..53c92eeb 100644
--- a/Core/Package.swift
+++ b/Core/Package.swift
@@ -122,6 +122,7 @@ let package = Package(
         .target(
             name: "HostApp",
             dependencies: [
+                .product(name: "CustomSuggestionService", package: "Tool"),
                 "Client",
                 "LaunchAgentManager",
                 "PlusFeatureFlag",
diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift
index 57e853d4..258ce38c 100644
--- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift
+++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift
@@ -3,6 +3,7 @@ import SwiftUI
 
 struct APIKeyPicker: View {
     @Perception.Bindable var store: StoreOf
+    var title: String = "API Key"
 
     var body: some View {
         WithPerceptionTracking {
@@ -27,7 +28,7 @@ struct APIKeyPicker: View {
                         }
 
                     },
-                    label: { Text("API Key") }
+                    label: { Text(title) }
                 )
 
                 Button(action: { store.send(.manageAPIKeysButtonClicked) }) {
diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CompletionModelEdit.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CompletionModelEdit.swift
new file mode 100644
index 00000000..d1ffb43b
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CompletionModelEdit.swift
@@ -0,0 +1,156 @@
+import AIModel
+import ComposableArchitecture
+import CustomSuggestionService
+import Preferences
+import Dependencies
+import SwiftUI
+
+@Reducer
+struct CompletionModelEdit {
+    @ObservableState
+    struct State: Equatable, Identifiable {
+        var id: String = "Custom"
+        var format: CompletionModel.Format
+        var maxTokens: Int = 4000
+        var modelName: String = ""
+        var apiKeyName: String { apiKeySelection.apiKeyName }
+        var baseURL: String { baseURLSelection.baseURL }
+        var availableModelNames: [String] = []
+        var availableAPIKeys: [String] = []
+        var suggestedMaxTokens: Int?
+        var apiKeySelection: APIKeySelection.State = .init()
+        var baseURLSelection: BaseURLSelection.State = .init()
+        var ollamaKeepAlive: String = ""
+    }
+
+    enum Action: Equatable, BindableAction {
+        case binding(BindingAction)
+        case appear
+        case saveButtonClicked
+        case refreshAvailableModelNames
+        case checkSuggestedMaxTokens
+        case readCustomModelFromDisk
+        case apiKeySelection(APIKeySelection.Action)
+        case baseURLSelection(BaseURLSelection.Action)
+    }
+
+    @Dependency(\.toast) var toast
+    @Dependency(\.apiKeyKeychain) var keychain
+
+    enum DebounceID: Hashable {
+        case save
+    }
+
+    var body: some Reducer {
+        BindingReducer()
+
+        Scope(state: \.apiKeySelection, action: \.apiKeySelection) {
+            APIKeySelection()
+        }
+
+        Scope(state: \.baseURLSelection, action: \.baseURLSelection) {
+            BaseURLSelection()
+        }
+
+        Reduce { state, action in
+            switch action {
+            case .appear:
+                return .run { send in
+                    await send(.readCustomModelFromDisk)
+                }
+
+            case .saveButtonClicked:
+                let model = CompletionModel(state: state)
+                return .run { _ in
+                    UserDefaults.shared.set(model, for: \.customSuggestionCompletionModel)
+                }
+
+            case .refreshAvailableModelNames:
+                if state.format == .openAI {
+                    state.availableModelNames = CustomSuggestionService.OpenAIService.ChatCompletionModels.allCases
+                        .map(\.rawValue)
+                }
+
+                return .none
+
+            case .readCustomModelFromDisk:
+                let model = UserDefaults.shared.value(for: \.customSuggestionCompletionModel)
+                state = model.toState()
+
+                return .run { send in
+                    await send(.checkSuggestedMaxTokens)
+                    await send(.refreshAvailableModelNames)
+                }
+
+            case .checkSuggestedMaxTokens:
+                switch state.format {
+                case .openAI:
+                    if let knownModel = CustomSuggestionService.OpenAIService.CompletionModels(rawValue: state.modelName) {
+                        state.suggestedMaxTokens = knownModel.maxToken
+                    } else {
+                        state.suggestedMaxTokens = nil
+                    }
+                    return .none
+                default:
+                    state.suggestedMaxTokens = nil
+                    return .none
+                }
+
+            case .apiKeySelection:
+                return .none
+
+            case .baseURLSelection:
+                return .none
+
+            case .binding(\.format):
+                return .run { send in
+                    await send(.refreshAvailableModelNames)
+                    await send(.checkSuggestedMaxTokens)
+                }
+
+            case .binding(\.modelName):
+                return .run { send in
+                    await send(.checkSuggestedMaxTokens)
+                }
+
+            case .binding:
+                return .none
+            }
+        }
+    }
+
+}
+
+extension CompletionModel {
+    func toState() -> CompletionModelEdit.State {
+        .init(
+            id: id,
+            format: format,
+            maxTokens: info.maxTokens,
+            modelName: info.modelName,
+            apiKeySelection: .init(
+                apiKeyName: info.apiKeyName,
+                apiKeyManagement: .init(availableAPIKeyNames: [info.apiKeyName])
+            ),
+            baseURLSelection: .init(baseURL: info.baseURL, isFullURL: info.isFullURL),
+            ollamaKeepAlive: info.ollamaInfo.keepAlive
+        )
+    }
+
+    init(state: CompletionModelEdit.State) {
+        self.init(
+            id: state.id,
+            name: "Custom Model (Completion API)",
+            format: state.format,
+            info: .init(
+                apiKeyName: state.apiKeyName,
+                baseURL: state.baseURL.trimmingCharacters(in: .whitespacesAndNewlines),
+                isFullURL: state.baseURLSelection.isFullURL,
+                maxTokens: state.maxTokens,
+                modelName: state.modelName.trimmingCharacters(in: .whitespacesAndNewlines),
+                ollamaInfo: .init(keepAlive: state.ollamaKeepAlive)
+            )
+        )
+    }
+}
+
diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CompletionModelEditView.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CompletionModelEditView.swift
new file mode 100644
index 00000000..18694e04
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CompletionModelEditView.swift
@@ -0,0 +1,324 @@
+import AIModel
+import ComposableArchitecture
+import CustomSuggestionService
+import Preferences
+import SwiftUI
+
+@MainActor
+struct CompletionModelEditView: View {
+    @Perception.Bindable var store: StoreOf
+
+    @Environment(\.dismiss) var dismiss
+
+    var body: some View {
+        WithPerceptionTracking {
+            ScrollView {
+                VStack(spacing: 0) {
+                    Form {
+                        formatPicker
+
+                        switch store.format {
+                        case .openAI:
+                            openAI
+                        case .azureOpenAI:
+                            azureOpenAI
+                        case .openAICompatible:
+                            openAICompatible
+                        case .ollama:
+                            ollama
+                        case .unknown:
+                            EmptyView()
+                        }
+                    }
+                    .padding()
+
+                    Divider()
+
+                    HStack {
+                        Spacer()
+
+                        Button("Cancel") {
+                            dismiss()
+                        }
+                        .keyboardShortcut(.cancelAction)
+
+                        Button(action: {
+                            store.send(.saveButtonClicked)
+                            dismiss()
+                        }) {
+                            Text("Save")
+                        }
+                        .keyboardShortcut(.defaultAction)
+                    }
+                    .padding()
+                }
+            }
+            .textFieldStyle(.roundedBorder)
+            .onAppear {
+                store.send(.appear)
+            }
+            .fixedSize(horizontal: false, vertical: true)
+        }
+    }
+
+    var formatPicker: some View {
+        Picker(
+            selection: $store.format,
+            content: {
+                ForEach(
+                    CompletionModel.Format.allCases,
+                    id: \.rawValue
+                ) { format in
+                    switch format {
+                    case .openAI:
+                        Text("OpenAI").tag(format)
+                    case .azureOpenAI:
+                        Text("Azure OpenAI").tag(format)
+                    case .openAICompatible:
+                        Text("OpenAI Compatible").tag(format)
+                    case .ollama:
+                        Text("Ollama").tag(format)
+                    case .unknown:
+                        EmptyView()
+                    }
+                }
+            },
+            label: { Text("Format") }
+        )
+        .pickerStyle(.segmented)
+    }
+
+    func baseURLTextField(
+        title: String = "Base URL",
+        prompt: Text?,
+        @ViewBuilder trailingContent: @escaping () -> V
+    ) -> some View {
+        BaseURLPicker(
+            title: title,
+            prompt: prompt,
+            store: store.scope(
+                state: \.baseURLSelection,
+                action: \.baseURLSelection
+            ),
+            trailingContent: trailingContent
+        )
+    }
+
+    func baseURLTextField(
+        title: String = "Base URL",
+        prompt: Text?
+    ) -> some View {
+        baseURLTextField(title: title, prompt: prompt, trailingContent: { EmptyView() })
+    }
+
+    var maxTokensTextField: some View {
+        HStack {
+            let textFieldBinding = Binding(
+                get: { String(store.maxTokens) },
+                set: {
+                    if let selectionMaxToken = Int($0) {
+                        $store.maxTokens.wrappedValue = selectionMaxToken
+                    } else {
+                        $store.maxTokens.wrappedValue = 0
+                    }
+                }
+            )
+
+            TextField(text: textFieldBinding) {
+                Text("Context Window")
+                    .multilineTextAlignment(.trailing)
+            }
+            .overlay(alignment: .trailing) {
+                Stepper(
+                    value: $store.maxTokens,
+                    in: 0...Int.max,
+                    step: 100
+                ) {
+                    EmptyView()
+                }
+            }
+            .foregroundColor({
+                guard let max = store.suggestedMaxTokens else {
+                    return .primary
+                }
+                if store.maxTokens > max {
+                    return .red
+                }
+                return .primary
+            }() as Color)
+
+            if let max = store.suggestedMaxTokens {
+                Text("Max: \(max)")
+            }
+        }
+    }
+
+    @ViewBuilder
+    var apiKeyNamePicker: some View {
+        APIKeyPicker(store: store.scope(
+            state: \.apiKeySelection,
+            action: \.apiKeySelection
+        ))
+    }
+
+    @ViewBuilder
+    var openAI: some View {
+        baseURLTextField(prompt: Text("https://api.openai.com")) {
+            Text("/v1/completions")
+        }
+        apiKeyNamePicker
+
+        TextField("Model Name", text: $store.modelName)
+            .overlay(alignment: .trailing) {
+                Picker(
+                    "",
+                    selection: $store.modelName,
+                    content: {
+                        if CustomSuggestionService.OpenAIService.ChatCompletionModels(rawValue: store.modelName) == nil {
+                            Text("Custom Model").tag(store.modelName)
+                        }
+                        ForEach(CustomSuggestionService.OpenAIService.ChatCompletionModels.allCases, id: \.self) { model in
+                            Text(model.rawValue).tag(model.rawValue)
+                        }
+                    }
+                )
+                .frame(width: 20)
+            }
+
+        maxTokensTextField
+
+        VStack(alignment: .leading, spacing: 8) {
+            Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
+                " To get an API key, please visit [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys)"
+            )
+        }
+        .padding(.vertical)
+    }
+
+    @ViewBuilder
+    var azureOpenAI: some View {
+        baseURLTextField(prompt: Text("https://xxxx.openai.azure.com"))
+        apiKeyNamePicker
+
+        TextField("Deployment Name", text: $store.modelName)
+
+        maxTokensTextField
+    }
+
+    @ViewBuilder
+    var openAICompatible: some View {
+        Picker(
+            selection: $store.baseURLSelection.isFullURL,
+            content: {
+                Text("Base URL").tag(false)
+                Text("Full URL").tag(true)
+            },
+            label: { Text("URL") }
+        )
+        .pickerStyle(.segmented)
+
+        baseURLTextField(
+            title: "",
+            prompt: store.baseURLSelection.isFullURL
+                ? Text("https://api.openai.com/v1/completions")
+                : Text("https://api.openai.com")
+        ) {
+            if !store.baseURLSelection.isFullURL {
+                Text("/v1/completions")
+            }
+        }
+        apiKeyNamePicker
+
+        TextField("Model Name", text: $store.modelName)
+
+        maxTokensTextField
+    }
+
+    @ViewBuilder
+    var googleAI: some View {
+        apiKeyNamePicker
+
+        TextField("Model Name", text: $store.modelName)
+            .overlay(alignment: .trailing) {
+                Picker(
+                    "",
+                    selection: $store.modelName,
+                    content: {
+                        if GoogleGeminiService.KnownModels(rawValue: store.modelName) == nil {
+                            Text("Custom Model").tag(store.modelName)
+                        }
+                        ForEach(GoogleGeminiService.KnownModels.allCases, id: \.self) { model in
+                            Text(model.rawValue).tag(model.rawValue)
+                        }
+                    }
+                )
+                .frame(width: 20)
+            }
+
+        maxTokensTextField
+    }
+
+    @ViewBuilder
+    var ollama: some View {
+        baseURLTextField(
+            title: "",
+            prompt: Text("https://127.0.0.1:11434/api/generate")
+        ) {
+            Text("/api/generate")
+        }
+
+        TextField("Model Name", text: $store.modelName)
+
+        maxTokensTextField
+        
+        TextField(text: $store.ollamaKeepAlive, prompt: Text("Default Value")) {
+            Text("Keep Alive")
+        }
+        
+        VStack(alignment: .leading, spacing: 8) {
+            Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
+                " For more details, please visit [https://ollama.com](https://ollama.com)"
+            )
+        }
+        .padding(.vertical)
+    }
+}
+
+#Preview("OpenAI") {
+    CompletionModelEditView(
+        store: .init(
+            initialState: CompletionModel(
+                id: "3",
+                name: "Test Model 3",
+                format: .openAI,
+                info: .init(
+                    apiKeyName: "key",
+                    baseURL: "apple.com",
+                    maxTokens: 3000,
+                    modelName: "gpt-3.5-turbo"
+                )
+            ).toState(),
+            reducer: { CompletionModelEdit() }
+        )
+    )
+}
+
+#Preview("OpenAI Compatible") {
+    CompletionModelEditView(
+        store: .init(
+            initialState: CompletionModel(
+                id: "3",
+                name: "Test Model 3",
+                format: .openAICompatible,
+                info: .init(
+                    apiKeyName: "key",
+                    baseURL: "apple.com",
+                    maxTokens: 3000,
+                    modelName: "gpt-3.5-turbo"
+                )
+            ).toState(),
+            reducer: { CompletionModelEdit() }
+        )
+    )
+}
+
diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestion.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestion.swift
new file mode 100644
index 00000000..3beacab8
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestion.swift
@@ -0,0 +1,35 @@
+import ComposableArchitecture
+import Foundation
+
+/// Settings of the built-in custom model suggestion provider.
+@Reducer
+struct CustomModelSuggestion {
+    @ObservableState
+    struct State: Equatable {
+        var completionModel = CompletionModelEdit.State(format: .openAI)
+        var fimModel = FIMModelEdit.State(format: .mistral)
+        var tabbyModel = TabbyModelEdit.State(
+            authorizationMode: .none,
+            authorizationHeaderName: "",
+            username: ""
+        )
+    }
+
+    enum Action {
+        case completionModel(CompletionModelEdit.Action)
+        case fimModel(FIMModelEdit.Action)
+        case tabbyModel(TabbyModelEdit.Action)
+    }
+
+    var body: some ReducerOf {
+        Scope(state: \.completionModel, action: \.completionModel) {
+            CompletionModelEdit()
+        }
+        Scope(state: \.fimModel, action: \.fimModel) {
+            FIMModelEdit()
+        }
+        Scope(state: \.tabbyModel, action: \.tabbyModel) {
+            TabbyModelEdit()
+        }
+    }
+}
diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift
new file mode 100644
index 00000000..88d2ea4c
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift
@@ -0,0 +1,216 @@
+import AIModel
+import ComposableArchitecture
+import CustomSuggestionService
+import Preferences
+import SwiftUI
+
+struct CustomModelSuggestionView: View {
+    final class Settings: ObservableObject {
+        @AppStorage(\.customSuggestionModelId) var modelId
+        @AppStorage(\.customSuggestionVerboseLog) var verboseLog
+        @AppStorage(\.customSuggestionMaxLines) var maxLines
+        @AppStorage(\.customSuggestionMaxGenerationToken) var maxGenerationToken
+    }
+
+    let store: StoreOf
+    @StateObject var settings = Settings()
+    @State var isEditingCustomModel = false
+
+    var body: some View {
+        WithPerceptionTracking {
+            let modelType = CustomModelType(rawValue: settings.modelId)
+            Form {
+                Section {
+                    HStack {
+                        CustomModelPicker()
+                        if modelType != nil {
+                            Button("Edit Model") {
+                                isEditingCustomModel = true
+                            }
+                        }
+                    }
+
+                    switch modelType {
+                    case .completionModel, nil:
+                        RequestStrategyPicker()
+                    case .tabby, .fimModel:
+                        EmptyView()
+                    }
+
+                    NumberInput(value: settings.$maxLines, range: 0...Int.max, step: 1) {
+                        Text("Suggestion Line Limit (0 for unlimited)")
+                    }
+
+                    NumberInput(
+                        value: settings.$maxGenerationToken,
+                        range: 0...Int.max,
+                        step: 100
+                    ) {
+                        Text("Suggestion Token Limit")
+                    }
+                } header: {
+                    Text("Custom Model")
+                } footer: {
+                    Text(
+                        "Generate suggestions with locally run or self-hosted models. Select \"Custom Model\" as the suggestion provider in Feature - Suggestion to use it."
+                    )
+                }
+
+                Section {
+                    Toggle("Verbose Log (Logs to Console.app)", isOn: settings.$verboseLog)
+                }
+            }
+            .formStyle(.grouped)
+            .sheet(isPresented: $isEditingCustomModel) {
+                switch CustomModelType(rawValue: settings.modelId) {
+                case .completionModel:
+                    CompletionModelEditView(store: store.scope(
+                        state: \.completionModel,
+                        action: \.completionModel
+                    ))
+                    .frame(width: 800)
+                case .fimModel:
+                    FIMModelEditView(store: store.scope(
+                        state: \.fimModel,
+                        action: \.fimModel
+                    ))
+                    .frame(width: 800)
+                case .tabby:
+                    TabbyModelEditView(store: store.scope(
+                        state: \.tabbyModel,
+                        action: \.tabbyModel
+                    ))
+                    .frame(width: 800)
+                case nil:
+                    EmptyView()
+                }
+            }
+        }
+    }
+}
+
+/// Picks either a built-in custom model type or one of the chat models configured in the host.
+struct CustomModelPicker: View {
+    final class Settings: ObservableObject {
+        @AppStorage(\.chatModels) var chatModels: [ChatModel]
+        @AppStorage(\.customSuggestionModelId) var modelId: String
+    }
+
+    @StateObject var settings = Settings()
+
+    var body: some View {
+        let unknownId: String? =
+            if !settings.chatModels.contains(where: { $0.id == settings.modelId }),
+            !settings.modelId.isEmpty,
+            CustomModelType(rawValue: settings.modelId) == nil
+        {
+            settings.modelId
+        } else {
+            nil
+        }
+
+        Picker(selection: settings.$modelId, label: Text("Model")) {
+            if let unknownId {
+                Text("Unknown Model (Use Custom Model Instead)").tag(unknownId)
+            }
+
+            ForEach(CustomModelType.allCases, id: \.rawValue) {
+                switch $0 {
+                case .completionModel:
+                    Text("Custom Model (Completion API)").tag($0.rawValue)
+                case .fimModel:
+                    Text("Custom Model (FIM API)").tag($0.rawValue)
+                case .tabby:
+                    Text("Tabby").tag($0.rawValue)
+                }
+            }
+
+            ForEach(settings.chatModels, id: \.id) { chatModel in
+                Text(chatModel.name).tag(chatModel.id)
+            }
+        }
+    }
+}
+
+struct RequestStrategyPicker: View {
+    final class Settings: ObservableObject {
+        @AppStorage(\.customSuggestionRequestStrategyId) var requestStrategyId
+        @AppStorage(\.customSuggestionFIMTemplate) var fimTemplate
+        @AppStorage(\.customSuggestionFIMPromptIsRaw) var fimPromptIsRaw
+        @AppStorage(\.customSuggestionFIMStopToken) var fimStopToken
+        @AppStorage(\.customSuggestionFIMAttachFileInfo) var fimAttachFileInfo
+    }
+
+    @StateObject var settings = Settings()
+
+    var body: some View {
+        let option = RequestStrategyOption(rawValue: settings.requestStrategyId)
+        let unknownId: String? = option == nil ? settings.requestStrategyId : nil
+
+        Picker(selection: settings.$requestStrategyId, label: Text("Request Strategy")) {
+            if let unknownId {
+                Text("Unknown Strategy (Use Default Strategy Instead)").tag(unknownId)
+            }
+
+            ForEach(RequestStrategyOption.allCases, id: \.rawValue) { option in
+                switch option {
+                case .default:
+                    Text("Default").tag(option.rawValue)
+                case .naive:
+                    Text("Naive").tag(option.rawValue)
+                case .continue:
+                    Text("Continue").tag(option.rawValue)
+                case .codeLlamaFillInTheMiddle:
+                    Text("Fill-in-the-Middle (for models with FIM support, e.g. codellama:xb-code)")
+                        .tag(option.rawValue)
+                case .codeLlamaFillInTheMiddleWithSystemPrompt:
+                    Text("Fill-in-the-Middle with System Prompt").tag(option.rawValue)
+                case .anthropic:
+                    Text("Anthropic Optimized").tag(option.rawValue)
+                }
+            }
+        }
+
+        if option == .codeLlamaFillInTheMiddle
+            || option == .codeLlamaFillInTheMiddleWithSystemPrompt
+        {
+            TextField(
+                text: $settings.fimTemplate,
+                prompt: Text(UserDefaultPreferenceKeys().customSuggestionFIMTemplate.defaultValue)
+            ) { Text("FIM Template") }
+            Toggle(isOn: $settings.fimPromptIsRaw) { Text("Raw Prompt") }
+            Toggle(isOn: $settings.fimAttachFileInfo) { Text("Attach File Info") }
+            TextField(text: $settings.fimStopToken) { Text("FIM Stop Token") }
+        }
+    }
+}
+
+private struct NumberInput: View {
+    @Binding var value: V
+    let formatter = NumberFormatter()
+    let range: ClosedRange
+    let step: V.Stride
+    @ViewBuilder var label: () -> Label
+
+    var body: some View {
+        TextField(value: .init(get: {
+            min(max(value, range.lowerBound), range.upperBound)
+        }, set: { newValue in
+            value = min(max(newValue, range.lowerBound), range.upperBound)
+        }), formatter: formatter, prompt: nil) {
+            label()
+        }
+        .padding(.trailing)
+        .overlay(alignment: .trailing) {
+            Stepper(value: $value, in: range, step: step) {
+                EmptyView()
+            }
+        }
+        .padding(.trailing, 4)
+    }
+}
+
+#Preview {
+    CustomModelSuggestionView(store: .init(initialState: .init(), reducer: { CustomModelSuggestion() }))
+        .frame(width: 800, height: 600)
+}
diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/FIMModelEdit.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/FIMModelEdit.swift
new file mode 100644
index 00000000..ea7b1c2f
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/FIMModelEdit.swift
@@ -0,0 +1,159 @@
+import AIModel
+import ComposableArchitecture
+import CustomSuggestionService
+import Preferences
+import Dependencies
+import SwiftUI
+
+@Reducer
+struct FIMModelEdit {
+    @ObservableState
+    struct State: Equatable, Identifiable {
+        var id: String = "Custom"
+        var format: FIMModel.Format
+        var maxTokens: Int = 4000
+        var modelName: String = ""
+        var apiKeyName: String { apiKeySelection.apiKeyName }
+        var baseURL: String { baseURLSelection.baseURL }
+        var availableModelNames: [String] = []
+        var availableAPIKeys: [String] = []
+        var suggestedMaxTokens: Int?
+        var apiKeySelection: APIKeySelection.State = .init()
+        var baseURLSelection: BaseURLSelection.State = .init()
+        var ollamaKeepAlive: String = ""
+        var authenticationMode: FIMModel.Info.AuthenticationMode = .bearerToken
+        var authenticationHeaderFieldName: String = ""
+    }
+
+    enum Action: Equatable, BindableAction {
+        case binding(BindingAction)
+        case appear
+        case saveButtonClicked
+        case refreshAvailableModelNames
+        case checkSuggestedMaxTokens
+        case readCustomModelFromDisk
+        case apiKeySelection(APIKeySelection.Action)
+        case baseURLSelection(BaseURLSelection.Action)
+    }
+
+    @Dependency(\.toast) var toast
+    @Dependency(\.apiKeyKeychain) var keychain
+
+    enum DebounceID: Hashable {
+        case save
+    }
+
+    var body: some Reducer {
+        BindingReducer()
+
+        Scope(state: \.apiKeySelection, action: \.apiKeySelection) {
+            APIKeySelection()
+        }
+
+        Scope(state: \.baseURLSelection, action: \.baseURLSelection) {
+            BaseURLSelection()
+        }
+
+        Reduce { state, action in
+            switch action {
+            case .appear:
+                return .run { send in
+                    await send(.readCustomModelFromDisk)
+                }
+
+            case .saveButtonClicked:
+                let model = FIMModel(state: state)
+                return .run { _ in
+                    UserDefaults.shared.set(model, for: \.customSuggestionFIMModel)
+                }
+
+            case .refreshAvailableModelNames:
+                if state.format == .mistral {
+                    state.availableModelNames = [
+                        "codestral-latest",
+                        "codestral-2405",
+                    ]
+                }
+
+                return .none
+
+            case .readCustomModelFromDisk:
+                let model = UserDefaults.shared.value(for: \.customSuggestionFIMModel)
+                state = model.toState()
+
+                return .run { send in
+                    await send(.checkSuggestedMaxTokens)
+                    await send(.refreshAvailableModelNames)
+                }
+
+            case .checkSuggestedMaxTokens:
+                switch state.format {
+                case .mistral:
+                    return .none
+                default:
+                    state.suggestedMaxTokens = nil
+                    return .none
+                }
+
+            case .apiKeySelection:
+                return .none
+
+            case .baseURLSelection:
+                return .none
+
+            case .binding(\.format):
+                return .run { send in
+                    await send(.refreshAvailableModelNames)
+                    await send(.checkSuggestedMaxTokens)
+                }
+
+            case .binding(\.modelName):
+                return .run { send in
+                    await send(.checkSuggestedMaxTokens)
+                }
+
+            case .binding:
+                return .none
+            }
+        }
+    }
+
+}
+
+extension FIMModel {
+    func toState() -> FIMModelEdit.State {
+        .init(
+            id: id,
+            format: format,
+            maxTokens: info.maxTokens,
+            modelName: info.modelName,
+            apiKeySelection: .init(
+                apiKeyName: info.apiKeyName,
+                apiKeyManagement: .init(availableAPIKeyNames: [info.apiKeyName])
+            ),
+            baseURLSelection: .init(baseURL: info.baseURL, isFullURL: info.isFullURL),
+            ollamaKeepAlive: info.ollamaInfo.keepAlive,
+            authenticationMode: info.authenticationMode,
+            authenticationHeaderFieldName: info.authenticationHeaderFieldName
+        )
+    }
+
+    init(state: FIMModelEdit.State) {
+        self.init(
+            id: state.id,
+            name: "Custom Model (Completion API)",
+            format: state.format,
+            info: .init(
+                apiKeyName: state.apiKeyName,
+                baseURL: state.baseURL.trimmingCharacters(in: .whitespacesAndNewlines),
+                isFullURL: state.baseURLSelection.isFullURL,
+                maxTokens: state.maxTokens,
+                modelName: state.modelName.trimmingCharacters(in: .whitespacesAndNewlines),
+                authenticationMode: state.authenticationMode,
+                authenticationHeaderFieldName: state.authenticationHeaderFieldName,
+                ollamaInfo: .init(keepAlive: state.ollamaKeepAlive)
+            )
+        )
+    }
+}
+
diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/FIMModelEditView.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/FIMModelEditView.swift
new file mode 100644
index 00000000..b81ceead
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/FIMModelEditView.swift
@@ -0,0 +1,313 @@
+import AIModel
+import ComposableArchitecture
+import CustomSuggestionService
+import Preferences
+import SwiftUI
+
+@MainActor
+struct FIMModelEditView: View {
+    @Perception.Bindable var store: StoreOf
+
+    @Environment(\.dismiss) var dismiss
+
+    var body: some View {
+        WithPerceptionTracking {
+            ScrollView {
+                VStack(spacing: 0) {
+                    Form {
+                        formatPicker
+
+                        switch store.format {
+                        case .mistral:
+                            mistralForm
+                        case .ollama:
+                            ollama
+                        case .ollamaCompatible:
+                            ollamaCompatible
+                        case .unknown:
+                            EmptyView()
+                        }
+                    }
+                    .padding()
+
+                    Divider()
+
+                    HStack {
+                        Spacer()
+
+                        Button("Cancel") {
+                            dismiss()
+                        }
+                        .keyboardShortcut(.cancelAction)
+
+                        Button(action: {
+                            store.send(.saveButtonClicked)
+                            dismiss()
+                        }) {
+                            Text("Save")
+                        }
+                        .keyboardShortcut(.defaultAction)
+                    }
+                    .padding()
+                }
+            }
+            .textFieldStyle(.roundedBorder)
+            .onAppear {
+                store.send(.appear)
+            }
+            .fixedSize(horizontal: false, vertical: true)
+        }
+    }
+
+    var formatPicker: some View {
+        Picker(
+            selection: $store.format,
+            content: {
+                ForEach(
+                    FIMModel.Format.allCases,
+                    id: \.rawValue
+                ) { format in
+                    switch format {
+                    case .mistral:
+                        Text("Mistral").tag(format)
+                    case .ollama:
+                        Text("Ollama").tag(format)
+                    case .ollamaCompatible:
+                        Text("Ollama Compatible").tag(format)
+                    case .unknown:
+                        EmptyView()
+                    }
+                }
+            },
+            label: { Text("Format") }
+        )
+        .pickerStyle(.segmented)
+    }
+
+    func baseURLTextField(
+        title: String = "Base URL",
+        prompt: Text?,
+        @ViewBuilder trailingContent: @escaping () -> V
+    ) -> some View {
+        BaseURLPicker(
+            title: title,
+            prompt: prompt,
+            store: store.scope(
+                state: \.baseURLSelection,
+                action: \.baseURLSelection
+            ),
+            trailingContent: trailingContent
+        )
+    }
+
+    func baseURLTextField(
+        title: String = "Base URL",
+        prompt: Text?
+    ) -> some View {
+        baseURLTextField(title: title, prompt: prompt, trailingContent: { EmptyView() })
+    }
+
+    var maxTokensTextField: some View {
+        HStack {
+            let textFieldBinding = Binding(
+                get: { String(store.maxTokens) },
+                set: {
+                    if let selectionMaxToken = Int($0) {
+                        $store.maxTokens.wrappedValue = selectionMaxToken
+                    } else {
+                        $store.maxTokens.wrappedValue = 0
+                    }
+                }
+            )
+
+            TextField(text: textFieldBinding) {
+                Text("Context Window")
+                    .multilineTextAlignment(.trailing)
+            }
+            .overlay(alignment: .trailing) {
+                Stepper(
+                    value: $store.maxTokens,
+                    in: 0...Int.max,
+                    step: 100
+                ) {
+                    EmptyView()
+                }
+            }
+            .foregroundColor({
+                guard let max = store.suggestedMaxTokens else {
+                    return .primary
+                }
+                if store.maxTokens > max {
+                    return .red
+                }
+                return .primary
+            }() as Color)
+
+            if let max = store.suggestedMaxTokens {
+                Text("Max: \(max)")
+            }
+        }
+    }
+
+    @ViewBuilder
+    var apiKeyNamePicker: some View {
+        APIKeyPicker(store: store.scope(
+            state: \.apiKeySelection,
+            action: \.apiKeySelection
+        ))
+    }
+
+    @ViewBuilder
+    var mistralForm: some View {
+        Picker(
+            selection: $store.baseURLSelection.isFullURL,
+            content: {
+                Text("Base URL").tag(false)
+                Text("Full URL").tag(true)
+            },
+            label: { Text("URL") }
+        )
+        .pickerStyle(.segmented)
+
+        baseURLTextField(
+            title: "",
+            prompt: store.baseURLSelection.isFullURL
+                ? Text("https://api.mistral.ai/v1/fim/completions")
+                : Text("https://api.mistral.ai")
+        ) {
+            if !store.baseURLSelection.isFullURL {
+                Text("/v1/fim/completions")
+            }
+        }
+        apiKeyNamePicker
+
+        TextField("Model Name", text: $store.modelName)
+            .overlay(alignment: .trailing) {
+                Picker(
+                    "",
+                    selection: $store.modelName,
+                    content: {
+                        if CustomSuggestionService.OpenAIService.ChatCompletionModels(rawValue: store.modelName) == nil {
+                            Text("Custom Model").tag(store.modelName)
+                        }
+                        ForEach(CustomSuggestionService.OpenAIService.ChatCompletionModels.allCases, id: \.self) { model in
+                            Text(model.rawValue).tag(model.rawValue)
+                        }
+                    }
+                )
+                .frame(width: 20)
+            }
+
+        maxTokensTextField
+        
+        VStack(alignment: .leading, spacing: 8) {
+            Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
+                """
+                 Compatible with:
+                - Mistral FIM API
+                - DeepSeek FIM API
+                
+                or any API that takes a prompt and a suffix and responds with 
+                an OpenAI-compatible streaming response, using bearer token authentication.
+                """
+            )
+        }
+        .padding(.vertical)
+    }
+
+    @ViewBuilder
+    var ollama: some View {
+        baseURLTextField(
+            title: "",
+            prompt: Text("https://127.0.0.1:11434")
+        ) {
+            Text("/api/generate")
+        }
+
+        TextField("Model Name", text: $store.modelName)
+
+        maxTokensTextField
+
+        TextField(text: $store.ollamaKeepAlive, prompt: Text("Default Value")) {
+            Text("Keep Alive")
+        }
+
+        VStack(alignment: .leading, spacing: 8) {
+            Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
+                " For more details, please visit [https://ollama.com](https://ollama.com)"
+            )
+        }
+        .padding(.vertical)
+    }
+
+    @ViewBuilder
+    var ollamaCompatible: some View {
+        Picker(
+            selection: $store.baseURLSelection.isFullURL,
+            content: {
+                Text("Base URL").tag(false)
+                Text("Full URL").tag(true)
+            },
+            label: { Text("URL") }
+        )
+        .pickerStyle(.segmented)
+
+        baseURLTextField(
+            title: "",
+            prompt: store.baseURLSelection.isFullURL
+                ? Text("https://127.0.0.1:11434/api/generate")
+                : Text("https://127.0.0.1:11434")
+        ) {
+            if !store.baseURLSelection.isFullURL {
+                Text("/api/generate")
+            }
+        }
+
+        Picker(
+            selection: $store.authenticationMode,
+            content: {
+                Text("Bearer Token").tag(FIMModel.Info.AuthenticationMode.bearerToken)
+                Text("Header Field").tag(FIMModel.Info.AuthenticationMode.header)
+            },
+            label: { Text("Authentication Mode") }
+        )
+        .pickerStyle(.segmented)
+
+        if store.authenticationMode == .header {
+            TextField("Header Field Name", text: $store.authenticationHeaderFieldName)
+        }
+
+        apiKeyNamePicker
+
+        TextField("Model Name", text: $store.modelName)
+
+        maxTokensTextField
+
+        VStack(alignment: .leading, spacing: 8) {
+            Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
+                " Use with any API that has the same format as Ollama. For more details, please visit [https://ollama.com](https://ollama.com)"
+            )
+        }
+        .padding(.vertical)
+    }
+}
+
+#Preview("Mistral") {
+    FIMModelEditView(
+        store: .init(
+            initialState: FIMModel(
+                id: "3",
+                name: "Test Model 3",
+                format: .mistral,
+                info: .init(
+                    apiKeyName: "key",
+                    baseURL: "apple.com",
+                    maxTokens: 3000,
+                    modelName: "gpt-3.5-turbo"
+                )
+            ).toState(),
+            reducer: { FIMModelEdit() }
+        )
+    )
+}
+
diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/TabbyModelEdit.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/TabbyModelEdit.swift
new file mode 100644
index 00000000..9e4f1e3c
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/TabbyModelEdit.swift
@@ -0,0 +1,104 @@
+import AIModel
+import ComposableArchitecture
+import CustomSuggestionService
+import Preferences
+import Dependencies
+import SwiftUI
+
+@Reducer
+struct TabbyModelEdit {
+    @ObservableState
+    struct State: Equatable {
+        var apiKeyName: String { apiKeySelection.apiKeyName }
+        var url: String { urlSelection.baseURL }
+        var apiKeySelection: APIKeySelection.State = .init()
+        var urlSelection: BaseURLSelection.State = .init()
+        var authorizationMode: TabbyModel.AuthorizationMode
+        var authorizationHeaderName: String
+        var username: String
+    }
+
+    enum Action: Equatable, BindableAction {
+        case binding(BindingAction)
+        case appear
+        case saveButtonClicked
+        case readCustomModelFromDisk
+        case apiKeySelection(APIKeySelection.Action)
+        case urlSelection(BaseURLSelection.Action)
+    }
+
+    @Dependency(\.toast) var toast
+    @Dependency(\.apiKeyKeychain) var keychain
+
+    enum DebounceID: Hashable {
+        case save
+    }
+
+    var body: some Reducer {
+        BindingReducer()
+
+        Scope(state: \.apiKeySelection, action: \.apiKeySelection) {
+            APIKeySelection()
+        }
+
+        Scope(state: \.urlSelection, action: \.urlSelection) {
+            BaseURLSelection()
+        }
+
+        Reduce { state, action in
+            switch action {
+            case .appear:
+                return .run { send in
+                    await send(.readCustomModelFromDisk)
+                }
+
+            case .saveButtonClicked:
+                let model = TabbyModel(state: state)
+                return .run { _ in
+                    UserDefaults.shared.set(model, for: \.customSuggestionTabbyModel)
+                }
+
+            case .readCustomModelFromDisk:
+                let model = UserDefaults.shared.value(for: \.customSuggestionTabbyModel)
+                state = model.toState()
+                return .none
+
+            case .apiKeySelection:
+                return .none
+
+            case .urlSelection:
+                return .none
+
+            case .binding:
+                return .none
+            }
+        }
+    }
+
+}
+
+extension TabbyModel {
+    func toState() -> TabbyModelEdit.State {
+        .init(
+            apiKeySelection: .init(
+                apiKeyName: apiKeyName,
+                apiKeyManagement: .init(availableAPIKeyNames: [apiKeyName])
+            ),
+            urlSelection: .init(baseURL: url),
+            authorizationMode: authorizationMode,
+            authorizationHeaderName: authorizationHeaderName,
+            username: username
+        )
+    }
+
+    init(state: TabbyModelEdit.State) {
+        self = .init(
+            url: state.urlSelection.baseURL,
+            authorizationMode: state.authorizationMode,
+            apiKeyName: state.apiKeySelection.apiKeyName,
+            authorizationHeaderName: state.authorizationHeaderName,
+            username: state.username
+        )
+    }
+}
+
diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/TabbyModelEditView.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/TabbyModelEditView.swift
new file mode 100644
index 00000000..287da334
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/TabbyModelEditView.swift
@@ -0,0 +1,167 @@
+import AIModel
+import ComposableArchitecture
+import CustomSuggestionService
+import Preferences
+import SwiftUI
+
+@MainActor
+struct TabbyModelEditView: View {
+    @Perception.Bindable var store: StoreOf
+
+    @Environment(\.dismiss) var dismiss
+
+    var body: some View {
+        WithPerceptionTracking {
+            ScrollView {
+                VStack(spacing: 0) {
+                    Form {
+                        form
+                    }
+                    .padding()
+
+                    Divider()
+
+                    HStack {
+                        Spacer()
+
+                        Button("Cancel") {
+                            dismiss()
+                        }
+                        .keyboardShortcut(.cancelAction)
+
+                        Button(action: {
+                            store.send(.saveButtonClicked)
+                            dismiss()
+                        }) {
+                            Text("Save")
+                        }
+                        .keyboardShortcut(.defaultAction)
+                    }
+                    .padding()
+                }
+            }
+            .textFieldStyle(.roundedBorder)
+            .onAppear {
+                store.send(.appear)
+            }
+            .fixedSize(horizontal: false, vertical: true)
+        }
+    }
+
+    var authorizationModePicker: some View {
+        Picker(
+            selection: $store.authorizationMode,
+            content: {
+                ForEach(
+                    TabbyModel.AuthorizationMode.allCases,
+                    id: \.rawValue
+                ) { format in
+                    switch format {
+                    case .none:
+                        Text("None").tag(format)
+                    case .bearerToken:
+                        Text("Bearer Token").tag(format)
+                    case .basic:
+                        Text("Basic").tag(format)
+                    case .customHeaderField:
+                        Text("Custom Header Field").tag(format)
+                    }
+                }
+            },
+            label: { Text("Format") }
+        )
+        .pickerStyle(.segmented)
+    }
+
+    func urlTextField(
+        title: String = "URL",
+        prompt: Text?
+    ) -> some View {
+        BaseURLPicker(
+            title: title,
+            prompt: prompt,
+            store: store.scope(
+                state: \.urlSelection,
+                action: \.urlSelection
+            )
+        ) {
+            EmptyView()
+        }
+    }
+
+    @ViewBuilder
+    func apiKeyNamePicker(title: String = "API Key") -> some View {
+        APIKeyPicker(store: store.scope(
+            state: \.apiKeySelection,
+            action: \.apiKeySelection
+        ), title: title)
+    }
+
+    @ViewBuilder
+    var form: some View {
+        urlTextField(prompt: Text("http://127.0.0.1:8080/v1/completions"))
+        authorizationModePicker
+
+        switch store.authorizationMode {
+        case .none:
+            EmptyView()
+        case .basic:
+            TextField("Username", text: $store.username)
+            apiKeyNamePicker(title: "Password")
+        case .customHeaderField:
+            TextField("Header Name", text: $store.authorizationHeaderName)
+            apiKeyNamePicker(title: "Value")
+        case .bearerToken:
+            apiKeyNamePicker(title: "Token")
+        }
+    }
+}
+
+#Preview("No Authorization") {
+    TabbyModelEditView(
+        store: .init(
+            initialState: TabbyModel(
+                url: "http://127.0.0.1", authorizationMode: .none, apiKeyName: "",
+                authorizationHeaderName: "Key", username: "User"
+            ).toState(),
+            reducer: { TabbyModelEdit() }
+        )
+    )
+}
+
+#Preview("Bearer Token") {
+    TabbyModelEditView(
+        store: .init(
+            initialState: TabbyModel(
+                url: "http://127.0.0.1", authorizationMode: .bearerToken, apiKeyName: "",
+                authorizationHeaderName: "Key", username: "User"
+            ).toState(),
+            reducer: { TabbyModelEdit() }
+        )
+    )
+}
+
+#Preview("Custom Header Field") {
+    TabbyModelEditView(
+        store: .init(
+            initialState: TabbyModel(
+                url: "http://127.0.0.1", authorizationMode: .customHeaderField, apiKeyName: "",
+                authorizationHeaderName: "Key", username: "User"
+            ).toState(),
+            reducer: { TabbyModelEdit() }
+        )
+    )
+}
+
+#Preview("Basic") {
+    TabbyModelEditView(
+        store: .init(
+            initialState: TabbyModel(
+                url: "http://127.0.0.1", authorizationMode: .basic, apiKeyName: "",
+                authorizationHeaderName: "Key", username: "User"
+            ).toState(),
+            reducer: { TabbyModelEdit() }
+        )
+    )
+}
+
diff --git a/Core/Sources/HostApp/AccountSettings/OtherSuggestionServicesView.swift b/Core/Sources/HostApp/AccountSettings/OtherSuggestionServicesView.swift
deleted file mode 100644
index 2497c9b5..00000000
--- a/Core/Sources/HostApp/AccountSettings/OtherSuggestionServicesView.swift
+++ /dev/null
@@ -1,31 +0,0 @@
-import Foundation
-import SwiftUI
-
-struct OtherSuggestionServicesView: View {
-    @Environment(\.openURL) var openURL
-    var body: some View {
-        VStack(alignment: .leading) {
-            Text(
-                "You can use other locally run services (Tabby, Ollma, etc.) to generate suggestions with the Custom Suggestion Service extension."
-            )
-            .lineLimit(nil)
-            .multilineTextAlignment(.leading)
-
-            Button(action: {
-                if let url = URL(
-                    string: "https://github.com/intitni/CustomSuggestionServiceForCopilotForXcode"
-                ) {
-                    openURL(url)
-                }
-            }) {
-                Text("Get It Now")
-            }
-        }
-    }
-}
-
-#Preview {
-    OtherSuggestionServicesView()
-        .frame(width: 200)
-}
-
diff --git a/Core/Sources/HostApp/HostApp.swift b/Core/Sources/HostApp/HostApp.swift
index f2b90303..312d7b70 100644
--- a/Core/Sources/HostApp/HostApp.swift
+++ b/Core/Sources/HostApp/HostApp.swift
@@ -19,6 +19,7 @@ struct HostApp {
         var chatModelManagement = ChatModelManagement.State()
         var embeddingModelManagement = EmbeddingModelManagement.State()
         var webSearchSettings = WebSearchSettings.State()
+        var customModelSuggestion = CustomModelSuggestion.State()
     }
 
     enum Action {
@@ -27,6 +28,7 @@ struct HostApp {
         case chatModelManagement(ChatModelManagement.Action)
         case embeddingModelManagement(EmbeddingModelManagement.Action)
         case webSearchSettings(WebSearchSettings.Action)
+        case customModelSuggestion(CustomModelSuggestion.Action)
     }
 
     @Dependency(\.toast) var toast
@@ -52,6 +54,10 @@ struct HostApp {
             WebSearchSettings()
         }
 
+        Scope(state: \.customModelSuggestion, action: \.customModelSuggestion) {
+            CustomModelSuggestion()
+        }
+
         Reduce { _, action in
             switch action {
             case .appear:
@@ -71,6 +77,9 @@ struct HostApp {
                 
             case .webSearchSettings:
                 return .none
+
+            case .customModelSuggestion:
+                return .none
             }
         }
     }
diff --git a/Core/Sources/HostApp/ServiceView.swift b/Core/Sources/HostApp/ServiceView.swift
index bf81eb51..112783ec 100644
--- a/Core/Sources/HostApp/ServiceView.swift
+++ b/Core/Sources/HostApp/ServiceView.swift
@@ -58,10 +58,13 @@ struct ServiceView: View {
                     )
 
                     ScrollView {
-                        OtherSuggestionServicesView().padding()
+                        CustomModelSuggestionView(store: store.scope(
+                            state: \.customModelSuggestion,
+                            action: \.customModelSuggestion
+                        ))
                     }.sidebarItem(
                         tag: 5,
-                        title: "Other Suggestion Services",
+                        title: "Custom Model",
                         subtitle: "Suggestion",
                         image: "globe"
                     )

From efe7879b854d465e63ec7e950d8a46be1fb6c02c Mon Sep 17 00:00:00 2001
From: Skyler Liu <9743878+JayDaniel@users.noreply.github.com>
Date: Mon, 7 Sep 2026 01:50:55 -0700
Subject: [PATCH 04/16] Sign with our own development team

Replace the upstream author's team in all 12 DEVELOPMENT_TEAM build
settings, so that the fork builds on this machine.

Claude-Session: https://claude.ai/code/session_01QrUJLx9ppwpa2DFUoSGUYo
---
 Copilot for Xcode.xcodeproj/project.pbxproj | 24 ++++++++++-----------
 1 file changed, 12 insertions(+), 12 deletions(-)

diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj
index 056e5761..fec097e7 100644
--- a/Copilot for Xcode.xcodeproj/project.pbxproj	
+++ b/Copilot for Xcode.xcodeproj/project.pbxproj	
@@ -776,7 +776,7 @@
 				COMBINE_HIDPI_IMAGES = YES;
 				CURRENT_PROJECT_VERSION = "$(APP_BUILD)";
 				DEAD_CODE_STRIPPING = YES;
-				DEVELOPMENT_TEAM = 5YKZ4Y3DAW;
+				DEVELOPMENT_TEAM = 94G4SCKS9Z;
 				ENABLE_HARDENED_RUNTIME = YES;
 				INFOPLIST_FILE = EditorExtension/Info.plist;
 				INFOPLIST_KEY_CFBundleDisplayName = "$(EXTENSION_BUNDLE_NAME)";
@@ -804,7 +804,7 @@
 				COMBINE_HIDPI_IMAGES = YES;
 				CURRENT_PROJECT_VERSION = "$(APP_BUILD)";
 				DEAD_CODE_STRIPPING = YES;
-				DEVELOPMENT_TEAM = 5YKZ4Y3DAW;
+				DEVELOPMENT_TEAM = 94G4SCKS9Z;
 				ENABLE_HARDENED_RUNTIME = YES;
 				INFOPLIST_FILE = EditorExtension/Info.plist;
 				INFOPLIST_KEY_CFBundleDisplayName = "$(EXTENSION_BUNDLE_NAME)";
@@ -955,7 +955,7 @@
 				CURRENT_PROJECT_VERSION = "$(APP_BUILD)";
 				DEAD_CODE_STRIPPING = YES;
 				DEVELOPMENT_ASSET_PATHS = "\"Copilot for Xcode/Preview Content\"";
-				DEVELOPMENT_TEAM = 5YKZ4Y3DAW;
+				DEVELOPMENT_TEAM = 94G4SCKS9Z;
 				ENABLE_HARDENED_RUNTIME = YES;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -989,7 +989,7 @@
 				CURRENT_PROJECT_VERSION = "$(APP_BUILD)";
 				DEAD_CODE_STRIPPING = YES;
 				DEVELOPMENT_ASSET_PATHS = "\"Copilot for Xcode/Preview Content\"";
-				DEVELOPMENT_TEAM = 5YKZ4Y3DAW;
+				DEVELOPMENT_TEAM = 94G4SCKS9Z;
 				ENABLE_HARDENED_RUNTIME = YES;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -1015,7 +1015,7 @@
 			buildSettings = {
 				CODE_SIGN_STYLE = Automatic;
 				DEAD_CODE_STRIPPING = YES;
-				DEVELOPMENT_TEAM = 5YKZ4Y3DAW;
+				DEVELOPMENT_TEAM = 94G4SCKS9Z;
 				ENABLE_HARDENED_RUNTIME = YES;
 				MACOSX_DEPLOYMENT_TARGET = 13.0;
 				PRODUCT_NAME = "$(TARGET_NAME)";
@@ -1029,7 +1029,7 @@
 			buildSettings = {
 				CODE_SIGN_STYLE = Automatic;
 				DEAD_CODE_STRIPPING = YES;
-				DEVELOPMENT_TEAM = 5YKZ4Y3DAW;
+				DEVELOPMENT_TEAM = 94G4SCKS9Z;
 				ENABLE_HARDENED_RUNTIME = YES;
 				MACOSX_DEPLOYMENT_TARGET = 13.0;
 				PRODUCT_NAME = "$(TARGET_NAME)";
@@ -1048,7 +1048,7 @@
 				COMBINE_HIDPI_IMAGES = YES;
 				CURRENT_PROJECT_VERSION = "$(APP_BUILD)";
 				DEAD_CODE_STRIPPING = YES;
-				DEVELOPMENT_TEAM = 5YKZ4Y3DAW;
+				DEVELOPMENT_TEAM = 94G4SCKS9Z;
 				ENABLE_HARDENED_RUNTIME = YES;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -1081,7 +1081,7 @@
 				COMBINE_HIDPI_IMAGES = YES;
 				CURRENT_PROJECT_VERSION = "$(APP_BUILD)";
 				DEAD_CODE_STRIPPING = YES;
-				DEVELOPMENT_TEAM = 5YKZ4Y3DAW;
+				DEVELOPMENT_TEAM = 94G4SCKS9Z;
 				ENABLE_HARDENED_RUNTIME = YES;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -1109,7 +1109,7 @@
 			buildSettings = {
 				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
 				CODE_SIGN_STYLE = Automatic;
-				DEVELOPMENT_TEAM = 5YKZ4Y3DAW;
+				DEVELOPMENT_TEAM = 94G4SCKS9Z;
 				ENABLE_HARDENED_RUNTIME = YES;
 				ENABLE_USER_SCRIPT_SANDBOXING = YES;
 				GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1128,7 +1128,7 @@
 			buildSettings = {
 				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
 				CODE_SIGN_STYLE = Automatic;
-				DEVELOPMENT_TEAM = 5YKZ4Y3DAW;
+				DEVELOPMENT_TEAM = 94G4SCKS9Z;
 				ENABLE_HARDENED_RUNTIME = YES;
 				ENABLE_USER_SCRIPT_SANDBOXING = YES;
 				GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1152,7 +1152,7 @@
 				COMBINE_HIDPI_IMAGES = YES;
 				CURRENT_PROJECT_VERSION = 1;
 				DEVELOPMENT_ASSET_PATHS = "\"SandboxedClientTester/Preview Content\"";
-				DEVELOPMENT_TEAM = 5YKZ4Y3DAW;
+				DEVELOPMENT_TEAM = 94G4SCKS9Z;
 				ENABLE_HARDENED_RUNTIME = YES;
 				ENABLE_PREVIEWS = YES;
 				ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1186,7 +1186,7 @@
 				COMBINE_HIDPI_IMAGES = YES;
 				CURRENT_PROJECT_VERSION = 1;
 				DEVELOPMENT_ASSET_PATHS = "\"SandboxedClientTester/Preview Content\"";
-				DEVELOPMENT_TEAM = 5YKZ4Y3DAW;
+				DEVELOPMENT_TEAM = 94G4SCKS9Z;
 				ENABLE_HARDENED_RUNTIME = YES;
 				ENABLE_PREVIEWS = YES;
 				ENABLE_USER_SCRIPT_SANDBOXING = YES;

From 225dfb4e5a4651cfac44f392d36a995140d52be9 Mon Sep 17 00:00:00 2001
From: Skyler Liu <9743878+JayDaniel@users.noreply.github.com>
Date: Mon, 7 Sep 2026 03:18:11 -0700
Subject: [PATCH 05/16] Support the Responses API and reasoning effort

- Add OpenAIResponsesService, covering both the streaming and the
  non-streaming shape of /v1/responses
- Switch OpenAIService to the reasoning request shape once an effort
  is set
- Forward the host chat model's custom headers through the OpenAI and
  the Anthropic service
- Add a Chat Model API section to the settings page for choosing the
  endpoint and the reasoning effort
- Cover SSE parsing and header placeholders with unit tests

Claude-Session: https://claude.ai/code/session_01QrUJLx9ppwpa2DFUoSGUYo
---
 .../CustomModelSuggestionView.swift           |  41 ++++
 .../API/AnthropicService.swift                |   6 +-
 .../API/OpenAIResponsesService.swift          | 221 ++++++++++++++++++
 .../API/OpenAIService.swift                   |  99 +++++---
 .../ChatAPISupport.swift                      |  87 +++++++
 .../CodeCompletionService.swift               |  68 ++++--
 .../CustomSuggestionServiceKeys.swift         |  10 +
 .../ChatAPIStreamParsingTests.swift           | 101 ++++++++
 8 files changed, 579 insertions(+), 54 deletions(-)
 create mode 100644 Tool/Sources/CustomSuggestionService/API/OpenAIResponsesService.swift
 create mode 100644 Tool/Sources/CustomSuggestionService/ChatAPISupport.swift
 create mode 100644 Tool/Tests/CustomSuggestionServiceTests/ChatAPIStreamParsingTests.swift

diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift
index 88d2ea4c..f1e7e0a4 100644
--- a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift
+++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift
@@ -56,6 +56,18 @@ struct CustomModelSuggestionView: View {
                     )
                 }
 
+                if modelType == nil {
+                    Section {
+                        ChatAPIOptionsPicker()
+                    } header: {
+                        Text("Chat Model API")
+                    } footer: {
+                        Text(
+                            "Applies to OpenAI and OpenAI compatible chat models. Setting a reasoning effort switches the request to the reasoning model shape: reasoning_effort is sent, max_completion_tokens replaces max_tokens, temperature and stop are omitted. Anthropic models always use /v1/messages."
+                        )
+                    }
+                }
+
                 Section {
                     Toggle("Verbose Log (Logs to Console.app)", isOn: settings.$verboseLog)
                 }
@@ -185,6 +197,35 @@ struct RequestStrategyPicker: View {
     }
 }
 
+struct ChatAPIOptionsPicker: View {
+    final class Settings: ObservableObject {
+        @AppStorage(\.customSuggestionOpenAIAPI) var openAIAPI
+        @AppStorage(\.customSuggestionReasoningEffort) var reasoningEffort
+    }
+
+    @StateObject var settings = Settings()
+
+    var body: some View {
+        Picker(selection: settings.$openAIAPI, label: Text("OpenAI Endpoint")) {
+            ForEach(OpenAIChatAPI.allCases, id: \.rawValue) { api in
+                switch api {
+                case .chatCompletions:
+                    Text("Chat Completions (/v1/chat/completions)").tag(api.rawValue)
+                case .responses:
+                    Text("Responses (/v1/responses)").tag(api.rawValue)
+                }
+            }
+        }
+
+        Picker(selection: settings.$reasoningEffort, label: Text("Reasoning Effort")) {
+            Text("Not Set").tag("")
+            ForEach(ReasoningEffort.allCases, id: \.rawValue) { effort in
+                Text(effort.rawValue.capitalized).tag(effort.rawValue)
+            }
+        }
+    }
+}
+
 private struct NumberInput: View {
     @Binding var value: V
     let formatter = NumberFormatter()
diff --git a/Tool/Sources/CustomSuggestionService/API/AnthropicService.swift b/Tool/Sources/CustomSuggestionService/API/AnthropicService.swift
index 86360a72..04b1f612 100644
--- a/Tool/Sources/CustomSuggestionService/API/AnthropicService.swift
+++ b/Tool/Sources/CustomSuggestionService/API/AnthropicService.swift
@@ -10,6 +10,7 @@ public actor AnthropicService {
     let temperature: Double
     let apiKey: String
     let stopWords: [String]
+    let extraHeaders: [(name: String, value: String)]
 
     init(
         url: String? = nil,
@@ -18,8 +19,10 @@ public actor AnthropicService {
         maxToken: Int,
         temperature: Double = 0.2,
         stopWords: [String] = [],
-        apiKey: String
+        apiKey: String,
+        extraHeaders: [(name: String, value: String)] = []
     ) {
+        self.extraHeaders = extraHeaders
         self.url = url.flatMap(URL.init(string:)) ??
             URL(string: "https://api.anthropic.com/v1/messages")!
         self.modelName = modelName
@@ -129,6 +132,7 @@ extension AnthropicService {
         request.setValue("application/json", forHTTPHeaderField: "content-type")
         request.setValue("\(apiKey)", forHTTPHeaderField: "x-api-key")
         request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version")
+        request.setExtraHeaders(extraHeaders)
 
         let encoder = JSONEncoder()
         request.httpBody = try encoder.encode(requestBody)
diff --git a/Tool/Sources/CustomSuggestionService/API/OpenAIResponsesService.swift b/Tool/Sources/CustomSuggestionService/API/OpenAIResponsesService.swift
new file mode 100644
index 00000000..7d16c1ee
--- /dev/null
+++ b/Tool/Sources/CustomSuggestionService/API/OpenAIResponsesService.swift
@@ -0,0 +1,221 @@
+import AIModel
+import CopilotForXcodeKit
+import Foundation
+
+/// `POST /v1/responses`
+///
+/// https://platform.openai.com/docs/api-reference/responses/create
+public actor OpenAIResponsesService {
+    let url: URL
+    let modelName: String
+    let contextWindow: Int
+    let maxToken: Int
+    let apiKey: String
+    let stopWords: [String]
+    let reasoningEffort: ReasoningEffort?
+    let extraHeaders: [(name: String, value: String)]
+
+    init(
+        url: String? = nil,
+        modelName: String,
+        contextWindow: Int,
+        maxToken: Int,
+        stopWords: [String] = [],
+        apiKey: String,
+        reasoningEffort: ReasoningEffort? = nil,
+        extraHeaders: [(name: String, value: String)] = []
+    ) {
+        self.url = url.flatMap(URL.init(string:))
+            ?? URL(string: "https://api.openai.com/v1/responses")!
+        self.modelName = modelName
+        self.contextWindow = contextWindow
+        self.maxToken = maxToken
+        self.apiKey = apiKey
+        self.stopWords = stopWords
+        self.reasoningEffort = reasoningEffort
+        self.extraHeaders = extraHeaders
+    }
+}
+
+extension OpenAIResponsesService: CodeCompletionServiceType {
+    typealias CompletionSequence = AsyncThrowingCompactMapSequence<
+        ResponseStream,
+        String
+    >
+
+    func getCompletion(_ request: PromptStrategy) async throws -> CompletionSequence {
+        let (instructions, input) = createInput(from: request)
+        CodeCompletionLogger.logger.logPrompt(
+            [(instructions, "instructions")] + input.map {
+                ($0.content.map(\.text).joined(), $0.role)
+            }
+        )
+        let result = try await send(instructions: instructions, input: input)
+        return result.compactMap { $0.text }
+    }
+}
+
+// MARK: - Request
+
+extension OpenAIResponsesService {
+    struct ContentPart: Encodable {
+        var type: String
+        var text: String
+    }
+
+    struct InputItem: Encodable {
+        var role: String
+        var content: [ContentPart]
+    }
+
+    struct RequestBody: Encodable {
+        struct Reasoning: Encodable {
+            var effort: String
+        }
+
+        var model: String
+        var input: [InputItem]
+        var instructions: String?
+        var stream: Bool
+        var max_output_tokens: Int
+        var reasoning: Reasoning?
+    }
+
+    func createInput(from request: PromptStrategy) -> (instructions: String, input: [InputItem]) {
+        let strategy = DefaultTruncateStrategy(maxTokenLimit: max(
+            contextWindow / 3 * 2,
+            contextWindow - maxToken - 20
+        ))
+        let prompts = strategy.createTruncatedPrompt(promptStrategy: request)
+        let input = prompts.map { prompt -> InputItem in
+            switch prompt.role {
+            case .user:
+                return .init(role: "user", content: [.init(type: "input_text", text: prompt.content)])
+            case .assistant:
+                return .init(
+                    role: "assistant",
+                    content: [.init(type: "output_text", text: prompt.content)]
+                )
+            }
+        }
+        return (request.systemPrompt, input)
+    }
+
+    func send(instructions: String, input: [InputItem]) async throws -> ResponseStream {
+        let requestBody = RequestBody(
+            model: modelName,
+            input: input,
+            instructions: instructions.isEmpty ? nil : instructions,
+            stream: true,
+            max_output_tokens: maxToken,
+            reasoning: reasoningEffort.map { .init(effort: $0.rawValue) }
+        )
+
+        var request = URLRequest(url: url)
+        request.httpMethod = "POST"
+        request.httpBody = try JSONEncoder().encode(requestBody)
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+        request.setExtraHeaders(extraHeaders)
+        let (result, response) = try await URLSession.shared.bytes(for: request)
+
+        guard let response = response as? HTTPURLResponse else {
+            throw CancellationError()
+        }
+
+        guard response.statusCode == 200 else {
+            let text = try await result.lines.reduce(into: "") { partialResult, current in
+                partialResult += current
+            }
+            throw Error.otherError(text)
+        }
+
+        return ResponseStream(result: result, lineExtractor: Self.parseStreamLine)
+    }
+}
+
+// MARK: - Response
+
+extension OpenAIResponsesService {
+    /// A text fragment extracted from one stream line.
+    struct StreamChunk {
+        var text: String?
+    }
+
+    struct APIError: Decodable {
+        var message: String?
+        var code: String?
+    }
+
+    struct OutputContent: Decodable {
+        var type: String?
+        var text: String?
+    }
+
+    struct OutputItem: Decodable {
+        var type: String?
+        var content: [OutputContent]?
+    }
+
+    struct ResponseObject: Decodable {
+        var object: String?
+        var status: String?
+        var output: [OutputItem]?
+        var error: APIError?
+
+        var outputText: String {
+            (output ?? [])
+                .filter { $0.type == "message" }
+                .flatMap { $0.content ?? [] }
+                .filter { $0.type == "output_text" }
+                .compactMap(\.text)
+                .joined()
+        }
+    }
+
+    /// Decodes both a stream event and a bare (non-streaming) response object.
+    struct StreamEvent: Decodable {
+        var type: String?
+        var delta: String?
+        var message: String?
+        var error: APIError?
+        var response: ResponseObject?
+        // Fields of a bare response object.
+        var object: String?
+        var output: [OutputItem]?
+    }
+
+    enum Error: Swift.Error, LocalizedError {
+        case apiError(String)
+        case otherError(String)
+
+        var errorDescription: String? {
+            switch self {
+            case let .apiError(message), let .otherError(message):
+                return message
+            }
+        }
+    }
+
+    /// Parses one line of the event stream, or a whole non-streaming JSON body.
+    static func parseStreamLine(_ line: String) throws -> ResponseStream.LineContent {
+        guard let payload = SSELine.payload(of: line) else { return .init(chunk: nil, done: false) }
+        if payload == "[DONE]" { return .init(chunk: nil, done: true) }
+        let event = try JSONDecoder().decode(StreamEvent.self, from: Data(payload.utf8))
+        switch event.type {
+        case "response.output_text.delta":
+            return .init(chunk: .init(text: event.delta), done: false)
+        case "response.completed", "response.incomplete":
+            return .init(chunk: nil, done: true)
+        case "response.failed":
+            throw Error.apiError(event.response?.error?.message ?? "The response failed.")
+        case "error":
+            throw Error.apiError(event.error?.message ?? event.message ?? "Unknown error.")
+        case .none where event.object == "response":
+            let body = ResponseObject(object: event.object, status: nil, output: event.output, error: nil)
+            return .init(chunk: .init(text: body.outputText), done: true)
+        default:
+            return .init(chunk: nil, done: false)
+        }
+    }
+}
diff --git a/Tool/Sources/CustomSuggestionService/API/OpenAIService.swift b/Tool/Sources/CustomSuggestionService/API/OpenAIService.swift
index e06df362..afa74ba8 100644
--- a/Tool/Sources/CustomSuggestionService/API/OpenAIService.swift
+++ b/Tool/Sources/CustomSuggestionService/API/OpenAIService.swift
@@ -11,6 +11,10 @@ public actor OpenAIService {
     let temperature: Double
     let apiKey: String
     let stopWords: [String]
+    /// When set, the request takes the reasoning model shape: `reasoning_effort` is sent,
+    /// `max_completion_tokens` replaces `max_tokens`, `temperature` and `stop` are omitted.
+    let reasoningEffort: ReasoningEffort?
+    let extraHeaders: [(name: String, value: String)]
 
     public enum Endpoint {
         case completion
@@ -25,8 +29,12 @@ public actor OpenAIService {
         maxToken: Int,
         temperature: Double = 0.2,
         stopWords: [String] = [],
-        apiKey: String
+        apiKey: String,
+        reasoningEffort: ReasoningEffort? = nil,
+        extraHeaders: [(name: String, value: String)] = []
     ) {
+        self.reasoningEffort = reasoningEffort
+        self.extraHeaders = extraHeaders
         self.url = url.flatMap(URL.init(string:)) ?? {
             switch endpoint {
             case .chatCompletion:
@@ -132,13 +140,16 @@ extension OpenAIService {
     func sendMessages(
         _ messages: [Message]
     ) async throws -> ResponseStream {
+        let isReasoningRequest = reasoningEffort != nil
         let requestBody = ChatCompletionRequestBody(
             model: modelName,
             messages: messages,
-            temperature: temperature,
+            temperature: isReasoningRequest ? nil : temperature,
             stream: true,
-            stop: stopWords,
-            max_tokens: maxToken
+            stop: isReasoningRequest ? nil : stopWords,
+            max_tokens: isReasoningRequest ? nil : maxToken,
+            max_completion_tokens: isReasoningRequest ? maxToken : nil,
+            reasoning_effort: reasoningEffort?.rawValue
         )
 
         var request = URLRequest(url: url)
@@ -147,6 +158,7 @@ extension OpenAIService {
         request.httpBody = try encoder.encode(requestBody)
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
         request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+        request.setExtraHeaders(extraHeaders)
         let (result, response) = try await URLSession.shared.bytes(for: request)
 
         guard let response = response as? HTTPURLResponse else {
@@ -160,22 +172,32 @@ extension OpenAIService {
             throw Error.otherError(text)
         }
 
-        return ResponseStream(result: result) {
-            var text = $0
-            if text.hasPrefix("data: ") {
-                text = String(text.dropFirst(6))
-            }
-            do {
-                let chunk = try JSONDecoder().decode(
-                    ChatCompletionsStreamDataChunk.self,
-                    from: text.data(using: .utf8) ?? Data()
-                )
-                return .init(chunk: chunk, done: chunk.choices?.first?.finish_reason != nil)
-            } catch {
-                print(error)
-                throw error
-            }
+        return ResponseStream(result: result, lineExtractor: Self.parseChatCompletionsStreamLine)
+    }
+
+    /// Parses one line of the event stream, or a whole non-streaming JSON body.
+    static func parseChatCompletionsStreamLine(
+        _ line: String
+    ) throws -> ResponseStream.LineContent {
+        guard let payload = SSELine.payload(of: line) else { return .init(chunk: nil, done: false) }
+        if payload == "[DONE]" { return .init(chunk: nil, done: true) }
+        let chunk = try JSONDecoder().decode(
+            ChatCompletionsStreamDataChunk.self,
+            from: Data(payload.utf8)
+        )
+        // A non-streaming body carries the whole message instead of a delta.
+        if let choice = chunk.choices?.first, choice.delta == nil, let message = choice.message {
+            return .init(
+                chunk: .init(choices: [.init(
+                    delta: .init(role: message.role, content: message.content),
+                    index: choice.index,
+                    finish_reason: choice.finish_reason ?? "stop",
+                    message: nil
+                )]),
+                done: true
+            )
         }
+        return .init(chunk: chunk, done: chunk.choices?.first?.finish_reason != nil)
     }
 
     /// https://platform.openai.com/docs/api-reference/chat/create
@@ -191,6 +213,9 @@ extension OpenAIService {
         var presence_penalty: Double?
         var frequency_penalty: Double?
         var logit_bias: [String: Double]?
+        /// Reasoning models reject `max_tokens` and expect this field instead.
+        var max_completion_tokens: Int?
+        var reasoning_effort: String?
 
         init(
             model: String,
@@ -203,8 +228,12 @@ extension OpenAIService {
             max_tokens: Int? = nil,
             presence_penalty: Double? = nil,
             frequency_penalty: Double? = nil,
-            logit_bias: [String: Double]? = nil
+            logit_bias: [String: Double]? = nil,
+            max_completion_tokens: Int? = nil,
+            reasoning_effort: String? = nil
         ) {
+            self.max_completion_tokens = max_completion_tokens
+            self.reasoning_effort = reasoning_effort
             self.model = model
             self.messages = messages
             self.temperature = temperature
@@ -249,6 +278,8 @@ extension OpenAIService {
             var delta: Delta?
             var index: Int?
             var finish_reason: String?
+            /// Present in a non-streaming response body.
+            var message: Message?
 
             struct Delta: Decodable {
                 var role: Message.Role?
@@ -290,6 +321,7 @@ extension OpenAIService {
         request.httpBody = try encoder.encode(requestBody)
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
         request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+        request.setExtraHeaders(extraHeaders)
         let (result, response) = try await URLSession.shared.bytes(for: request)
 
         guard let response = response as? HTTPURLResponse else {
@@ -303,22 +335,17 @@ extension OpenAIService {
             throw Error.otherError(text)
         }
 
-        return ResponseStream(result: result) {
-            var text = $0
-            if text.hasPrefix("data: ") {
-                text = String(text.dropFirst(6))
-            }
-            do {
-                let chunk = try JSONDecoder().decode(
-                    CompletionsStreamDataChunk.self,
-                    from: text.data(using: .utf8) ?? Data()
-                )
-                return .init(chunk: chunk, done: chunk.choices?.first?.finish_reason != nil)
-            } catch {
-                print(error)
-                throw error
-            }
-        }
+        return ResponseStream(result: result, lineExtractor: Self.parseCompletionsStreamLine)
+    }
+
+    /// Parses one line of the event stream, or a whole non-streaming JSON body.
+    static func parseCompletionsStreamLine(
+        _ line: String
+    ) throws -> ResponseStream.LineContent {
+        guard let payload = SSELine.payload(of: line) else { return .init(chunk: nil, done: false) }
+        if payload == "[DONE]" { return .init(chunk: nil, done: true) }
+        let chunk = try JSONDecoder().decode(CompletionsStreamDataChunk.self, from: Data(payload.utf8))
+        return .init(chunk: chunk, done: chunk.choices?.first?.finish_reason != nil)
     }
 
     /// https://platform.openai.com/docs/api-reference/chat/create
diff --git a/Tool/Sources/CustomSuggestionService/ChatAPISupport.swift b/Tool/Sources/CustomSuggestionService/ChatAPISupport.swift
new file mode 100644
index 00000000..637d6e87
--- /dev/null
+++ b/Tool/Sources/CustomSuggestionService/ChatAPISupport.swift
@@ -0,0 +1,87 @@
+import AIModel
+import Foundation
+import Preferences
+
+/// Which OpenAI endpoint to call for chat models.
+public enum OpenAIChatAPI: String, CaseIterable {
+    /// `POST /v1/chat/completions`
+    case chatCompletions
+    /// `POST /v1/responses`
+    case responses
+}
+
+/// `reasoning_effort` (Chat Completions) / `reasoning.effort` (Responses).
+///
+/// The raw value is sent verbatim. When the preference is empty the field is omitted and the
+/// request keeps the legacy shape (`temperature`, `stop`, `max_tokens`).
+public enum ReasoningEffort: String, CaseIterable {
+    case none
+    case minimal
+    case low
+    case medium
+    case high
+}
+
+/// Per-request options for chat models, resolved from preferences and the model's custom headers.
+struct ChatAPIOptions {
+    var api: OpenAIChatAPI
+    var reasoningEffort: ReasoningEffort?
+    var extraHeaders: [(name: String, value: String)]
+
+    static func current(for model: ChatModel, apiKey: String) -> ChatAPIOptions {
+        let api = OpenAIChatAPI(
+            rawValue: UserDefaults.shared.value(for: \.customSuggestionOpenAIAPI)
+        ) ?? .chatCompletions
+        let effort = ReasoningEffort(
+            rawValue: UserDefaults.shared.value(for: \.customSuggestionReasoningEffort)
+        )
+        let headers = model.info.customHeaderInfo.headers
+            .filter { !$0.key.isEmpty }
+            .map { field in
+                (
+                    name: field.key,
+                    value: substitutePlaceholders(
+                        in: field.value,
+                        modelName: model.info.modelName,
+                        apiKey: apiKey
+                    )
+                )
+            }
+        return .init(api: api, reasoningEffort: effort, extraHeaders: headers)
+    }
+
+    /// Supports the `{{api_key}}` and `{{model_name}}` placeholders of the host's custom headers.
+    static func substitutePlaceholders(in value: String, modelName: String, apiKey: String) -> String {
+        value
+            .replacingOccurrences(of: "{{api_key}}", with: apiKey)
+            .replacingOccurrences(of: "{{model_name}}", with: modelName)
+    }
+}
+
+extension URLRequest {
+    mutating func setExtraHeaders(_ headers: [(name: String, value: String)]) {
+        for header in headers {
+            setValue(header.value, forHTTPHeaderField: header.name)
+        }
+    }
+}
+
+/// Helpers for `text/event-stream` lines.
+enum SSELine {
+    /// The payload of a stream line, or nil for lines that carry no data
+    /// (blank lines, `event:` / `id:` / `retry:` lines and comments).
+    ///
+    /// A line without the `data:` prefix is returned as is, so a non-streaming JSON body can be
+    /// handled by the same parser.
+    static func payload(of line: String) -> String? {
+        let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
+        if trimmed.isEmpty { return nil }
+        for prefix in [":", "event:", "id:", "retry:"] where trimmed.hasPrefix(prefix) {
+            return nil
+        }
+        if trimmed.hasPrefix("data:") {
+            return String(trimmed.dropFirst(5)).trimmingCharacters(in: .whitespaces)
+        }
+        return trimmed
+    }
+}
diff --git a/Tool/Sources/CustomSuggestionService/CodeCompletionService.swift b/Tool/Sources/CustomSuggestionService/CodeCompletionService.swift
index 9b704f10..45598132 100644
--- a/Tool/Sources/CustomSuggestionService/CodeCompletionService.swift
+++ b/Tool/Sources/CustomSuggestionService/CodeCompletionService.swift
@@ -93,24 +93,49 @@ public struct CodeCompletionService {
     ) async throws -> [String] {
         let apiKey = apiKey(from: model)
 
+        let options = ChatAPIOptions.current(for: model, apiKey: apiKey)
+
         switch model.format {
         case .openAI, .openAICompatible:
-            let service = OpenAIService(
-                url: model.endpoint,
-                endpoint: .chatCompletion,
-                modelName: model.info.modelName,
-                contextWindow: model.info.maxTokens,
-                maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken),
-                stopWords: prompt.stopWords,
-                apiKey: apiKey
-            )
-            let result = try await service.getCompletions(
-                prompt,
-                streamStopStrategy: streamStopStrategy,
-                count: count
-            )
-            try Task.checkCancellation()
-            return result
+            switch options.api {
+            case .responses:
+                let service = OpenAIResponsesService(
+                    url: Self.responsesEndpoint(for: model),
+                    modelName: model.info.modelName,
+                    contextWindow: model.info.maxTokens,
+                    maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken),
+                    stopWords: prompt.stopWords,
+                    apiKey: apiKey,
+                    reasoningEffort: options.reasoningEffort,
+                    extraHeaders: options.extraHeaders
+                )
+                let result = try await service.getCompletions(
+                    prompt,
+                    streamStopStrategy: streamStopStrategy,
+                    count: count
+                )
+                try Task.checkCancellation()
+                return result
+            case .chatCompletions:
+                let service = OpenAIService(
+                    url: model.endpoint,
+                    endpoint: .chatCompletion,
+                    modelName: model.info.modelName,
+                    contextWindow: model.info.maxTokens,
+                    maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken),
+                    stopWords: prompt.stopWords,
+                    apiKey: apiKey,
+                    reasoningEffort: options.reasoningEffort,
+                    extraHeaders: options.extraHeaders
+                )
+                let result = try await service.getCompletions(
+                    prompt,
+                    streamStopStrategy: streamStopStrategy,
+                    count: count
+                )
+                try Task.checkCancellation()
+                return result
+            }
         case .azureOpenAI:
             let service = AzureOpenAIService(
                 url: model.endpoint,
@@ -167,7 +192,8 @@ public struct CodeCompletionService {
                 contextWindow: model.info.maxTokens,
                 maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken),
                 stopWords: prompt.stopWords,
-                apiKey: apiKey
+                apiKey: apiKey,
+                extraHeaders: options.extraHeaders
             )
             let result = try await service.getCompletions(
                 prompt,
@@ -325,6 +351,14 @@ public struct CodeCompletionService {
         }
     }
 
+    /// `ChatModel.endpoint` always points at `/v1/chat/completions`; derive the Responses API URL.
+    static func responsesEndpoint(for model: ChatModel) -> String {
+        let baseURL = model.info.baseURL
+        if baseURL.isEmpty { return "https://api.openai.com/v1/responses" }
+        if model.format == .openAICompatible, model.info.isFullURL { return baseURL }
+        return "\(baseURL)/v1/responses"
+    }
+
     func apiKey(from model: ChatModel) -> String {
         let name = model.info.apiKeyName
         return (try? Keychain.apiKey.get(name)) ?? ""
diff --git a/Tool/Sources/Preferences/CustomSuggestionServiceKeys.swift b/Tool/Sources/Preferences/CustomSuggestionServiceKeys.swift
index a042b638..66122cfb 100644
--- a/Tool/Sources/Preferences/CustomSuggestionServiceKeys.swift
+++ b/Tool/Sources/Preferences/CustomSuggestionServiceKeys.swift
@@ -51,6 +51,16 @@ public extension UserDefaultPreferenceKeys {
         .init(defaultValue: "", key: "CustomSuggestionService-RequestStrategyId")
     }
 
+    /// Raw value of `OpenAIChatAPI`: which endpoint OpenAI (compatible) chat models are called with.
+    var customSuggestionOpenAIAPI: PreferenceKey {
+        .init(defaultValue: "chatCompletions", key: "CustomSuggestionService-OpenAIAPI")
+    }
+
+    /// Raw value of `ReasoningEffort`. Empty means the field is not sent.
+    var customSuggestionReasoningEffort: PreferenceKey {
+        .init(defaultValue: "", key: "CustomSuggestionService-ReasoningEffort")
+    }
+
     /// 0 means unlimited.
     var customSuggestionMaxLines: PreferenceKey {
         .init(defaultValue: 0, key: "CustomSuggestionService-MaxNumberOfLinesOfSuggestion")
diff --git a/Tool/Tests/CustomSuggestionServiceTests/ChatAPIStreamParsingTests.swift b/Tool/Tests/CustomSuggestionServiceTests/ChatAPIStreamParsingTests.swift
new file mode 100644
index 00000000..df913f08
--- /dev/null
+++ b/Tool/Tests/CustomSuggestionServiceTests/ChatAPIStreamParsingTests.swift
@@ -0,0 +1,101 @@
+import Foundation
+import XCTest
+
+@testable import CustomSuggestionService
+
+class ChatAPIStreamParsingTests: XCTestCase {
+    // MARK: Responses API
+
+    func test_responses_delta_event() throws {
+        let line = #"data: {"type":"response.output_text.delta","item_id":"msg_1","delta":"Hel"}"#
+        let result = try OpenAIResponsesService.parseStreamLine(line)
+        XCTAssertEqual(result.chunk?.text, "Hel")
+        XCTAssertFalse(result.done)
+    }
+
+    func test_responses_ignores_lines_without_text() throws {
+        for line in [
+            "",
+            "event: response.output_text.delta",
+            ": keep-alive",
+            #"data: {"type":"response.created","response":{"id":"resp_1","object":"response"}}"#,
+            #"data: {"type":"response.output_text.done","text":"Hello"}"#,
+        ] {
+            let result = try OpenAIResponsesService.parseStreamLine(line)
+            XCTAssertNil(result.chunk?.text, line)
+            XCTAssertFalse(result.done, line)
+        }
+    }
+
+    func test_responses_completed_event_finishes_the_stream() throws {
+        let line = #"data: {"type":"response.completed","response":{"id":"resp_1","object":"response","status":"completed"}}"#
+        let result = try OpenAIResponsesService.parseStreamLine(line)
+        XCTAssertNil(result.chunk?.text)
+        XCTAssertTrue(result.done)
+        XCTAssertTrue(try OpenAIResponsesService.parseStreamLine("data: [DONE]").done)
+    }
+
+    func test_responses_error_events_throw() {
+        XCTAssertThrowsError(try OpenAIResponsesService.parseStreamLine(
+            #"data: {"type":"error","code":"rate_limit","message":"slow down"}"#
+        )) { XCTAssertEqual($0.localizedDescription, "slow down") }
+        XCTAssertThrowsError(try OpenAIResponsesService.parseStreamLine(
+            #"data: {"type":"response.failed","response":{"object":"response","status":"failed","error":{"message":"boom"}}}"#
+        )) { XCTAssertEqual($0.localizedDescription, "boom") }
+    }
+
+    func test_responses_non_streaming_body() throws {
+        let body = """
+        {"id":"resp_abc123","object":"response","model":"gpt-5.5","status":"completed",\
+        "output":[{"type":"reasoning","summary":[]},\
+        {"type":"message","role":"assistant","content":[{"type":"output_text","text":"Hel"},{"type":"output_text","text":"lo!"}]}],\
+        "usage":{"input_tokens":12,"output_tokens":45,"total_tokens":57}}
+        """
+        let result = try OpenAIResponsesService.parseStreamLine(body)
+        XCTAssertEqual(result.chunk?.text, "Hello!")
+        XCTAssertTrue(result.done)
+    }
+
+    // MARK: Chat Completions API
+
+    func test_chatCompletions_delta_and_done_marker() throws {
+        let delta = #"data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":null}]}"#
+        let result = try OpenAIService.parseChatCompletionsStreamLine(delta)
+        XCTAssertEqual(result.chunk?.choices?.first?.delta?.content, "Hi")
+        XCTAssertFalse(result.done)
+
+        let finished = #"data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#
+        XCTAssertTrue(try OpenAIService.parseChatCompletionsStreamLine(finished).done)
+        XCTAssertTrue(try OpenAIService.parseChatCompletionsStreamLine("data: [DONE]").done)
+        XCTAssertFalse(try OpenAIService.parseChatCompletionsStreamLine("").done)
+    }
+
+    func test_chatCompletions_non_streaming_body() throws {
+        let body = #"{"id":"chatcmpl-abc123","object":"chat.completion","model":"gpt-5.5","choices":[{"index":0,"message":{"role":"assistant","content":"Hello! How can I help you today?"},"finish_reason":"stop"}]}"#
+        let result = try OpenAIService.parseChatCompletionsStreamLine(body)
+        XCTAssertEqual(result.chunk?.choices?.first?.delta?.content, "Hello! How can I help you today?")
+        XCTAssertTrue(result.done)
+    }
+
+    // MARK: Helpers
+
+    func test_sse_payload() {
+        XCTAssertEqual(SSELine.payload(of: "data: {}"), "{}")
+        XCTAssertEqual(SSELine.payload(of: "data:{}"), "{}")
+        XCTAssertEqual(SSELine.payload(of: "{\"a\":1}"), "{\"a\":1}")
+        XCTAssertNil(SSELine.payload(of: "event: x"))
+        XCTAssertNil(SSELine.payload(of: "id: 1"))
+        XCTAssertNil(SSELine.payload(of: "   "))
+    }
+
+    func test_header_placeholders() {
+        XCTAssertEqual(
+            ChatAPIOptions.substitutePlaceholders(
+                in: "Bearer {{api_key}} for {{model_name}}",
+                modelName: "gpt-5.5",
+                apiKey: "sk-1"
+            ),
+            "Bearer sk-1 for gpt-5.5"
+        )
+    }
+}

From 045a64617c412aa23fce37c7321b5e80a8c28948 Mon Sep 17 00:00:00 2001
From: Skyler Liu <9743878+JayDaniel@users.noreply.github.com>
Date: Mon, 7 Sep 2026 03:18:11 -0700
Subject: [PATCH 06/16] Register the launch agent on debug launch

A debug build could not reach its services until the launch agent was
installed by hand, and the host insisted on being moved to
/Applications first.

- Add an idempotent debug-only registration path: write the plist,
  bootstrap it, and kickstart the bridge once it is rebuilt
- Register on host launch in debug, and drop the move to Applications
  prompt there
- Let the bridge start ExtensionService in debug as well
- Have ExtensionService exit in debug once its binary changes, so it
  comes back running the new code
- Leave release behaviour identical to upstream

Claude-Session: https://claude.ai/code/session_01QrUJLx9ppwpa2DFUoSGUYo
---
 CommunicationBridge/ServiceDelegate.swift     |   7 -
 Core/Sources/HostApp/General.swift            |  14 +-
 .../LaunchAgentManager+DebugBuild.swift       | 154 ++++++++++++++++++
 .../LaunchAgentManager.swift                  |   4 +
 ExtensionService/AppDelegate.swift            |   3 -
 5 files changed, 167 insertions(+), 15 deletions(-)
 create mode 100644 Core/Sources/LaunchAgentManager/LaunchAgentManager+DebugBuild.swift

diff --git a/CommunicationBridge/ServiceDelegate.swift b/CommunicationBridge/ServiceDelegate.swift
index 8a064aef..085b4813 100644
--- a/CommunicationBridge/ServiceDelegate.swift
+++ b/CommunicationBridge/ServiceDelegate.swift
@@ -62,12 +62,6 @@ actor EventHandler {
         withReply reply: @escaping (NSXPCListenerEndpoint?) -> Void
     ) async {
         rescheduleExitTask()
-        #if DEBUG
-        if let endpoint, !(await testXPCListenerEndpoint(endpoint)) {
-            self.endpoint = nil
-        }
-        reply(endpoint)
-        #else
         if await launcher.isApplicationValid {
             Logger.communicationBridge.info("Service app is still valid")
             reply(endpoint)
@@ -76,7 +70,6 @@ actor EventHandler {
             await launcher.launch()
             reply(nil)
         }
-        #endif
     }
 
     func quit(withReply reply: () -> Void) {
diff --git a/Core/Sources/HostApp/General.swift b/Core/Sources/HostApp/General.swift
index 96ade16c..b8818e55 100644
--- a/Core/Sources/HostApp/General.swift
+++ b/Core/Sources/HostApp/General.swift
@@ -53,11 +53,19 @@ struct General {
         Bundle.main.bundleURL.path.hasPrefix("/Applications")
     }
 
+    static var isDebugBuild: Bool {
+        #if DEBUG
+        true
+        #else
+        false
+        #endif
+    }
+
     var body: some ReducerOf {
         Reduce { state, action in
             switch action {
             case .appear:
-                if Self.bundleIsInApplicationsFolder {
+                if Self.bundleIsInApplicationsFolder || Self.isDebugBuild {
                     return .run { send in
                         await send(.setupLaunchAgentIfNeeded)
                     }
@@ -85,16 +93,12 @@ struct General {
 
             case .setupLaunchAgentIfNeeded:
                 return .run { send in
-                    #if DEBUG
-                    // do not auto install on debug build
-                    #else
                     do {
                         try await LaunchAgentManager()
                             .setupLaunchAgentForTheFirstTimeIfNeeded()
                     } catch {
                         toast(error.localizedDescription, .error)
                     }
-                    #endif
                     await send(.reloadStatus)
                 }
 
diff --git a/Core/Sources/LaunchAgentManager/LaunchAgentManager+DebugBuild.swift b/Core/Sources/LaunchAgentManager/LaunchAgentManager+DebugBuild.swift
new file mode 100644
index 00000000..611e702a
--- /dev/null
+++ b/Core/Sources/LaunchAgentManager/LaunchAgentManager+DebugBuild.swift
@@ -0,0 +1,154 @@
+#if DEBUG
+import Foundation
+import os
+
+/// Debug builds run straight from DerivedData, so the release flow (`SMAppService` with the
+/// plist bundled for /Applications) can't be used. Upstream expects developers to run every
+/// target from Xcode by hand; this fork instead registers the bridge automatically so a plain
+/// "Run" of the host app brings the whole service chain up.
+extension LaunchAgentManager {
+    private static let logger = os.Logger(
+        subsystem: "com.intii.CopilotForXcode",
+        category: "LaunchAgentManager"
+    )
+
+    /// Modification date of the bridge executable the last time launchd (re)started it.
+    private static let bridgeModificationDateKey = "DebugBuild.LaunchAgentBridgeModificationDate"
+
+    /// Idempotent, safe to call on every launch:
+    /// 1. writes `~/Library/LaunchAgents/.plist`, replacing it when the
+    ///    executable path changed (for example a new DerivedData location);
+    /// 2. bootstraps it into the user's launchd domain when it isn't loaded;
+    /// 3. restarts the bridge when its binary was rebuilt since the last restart, so the freshly
+    ///    built code is the one serving XPC requests.
+    public func setupLaunchAgentForDebugBuild() async throws {
+        let plist = debugPlistContent
+        let existing = try? String(contentsOfFile: launchAgentPath, encoding: .utf8)
+        let isLoaded = await isDebugLaunchAgentLoaded()
+        let bridgeModificationDate = try? FileManager.default
+            .attributesOfItem(atPath: executableURL.path)[.modificationDate] as? Date
+        let defaults = UserDefaults.standard
+        let lastBridgeModificationDate = defaults
+            .object(forKey: Self.bridgeModificationDateKey) as? Date
+
+        if existing != plist {
+            if isLoaded {
+                try await debugLaunchctl("bootout", serviceTarget)
+                await waitUntilDebugLaunchAgentUnloaded()
+            }
+            try writeDebugPlist(plist)
+            try await debugLaunchctl("bootstrap", domainTarget, launchAgentPath)
+            Self.logger.info(
+                "Registered \(serviceIdentifier, privacy: .public) -> \(executableURL.path, privacy: .public)"
+            )
+        } else if !isLoaded {
+            try await debugLaunchctl("bootstrap", domainTarget, launchAgentPath)
+            Self.logger.info("Bootstrapped \(serviceIdentifier, privacy: .public)")
+        } else if let bridgeModificationDate, bridgeModificationDate != lastBridgeModificationDate {
+            try await debugLaunchctl("kickstart", "-k", serviceTarget)
+            Self.logger.info("Restarted the bridge because its executable was rebuilt")
+        }
+
+        defaults.set(bridgeModificationDate, forKey: Self.bridgeModificationDateKey)
+    }
+}
+
+private extension LaunchAgentManager {
+    var domainTarget: String { "gui/\(getuid())" }
+    var serviceTarget: String { "\(domainTarget)/\(serviceIdentifier)" }
+
+    /// Same template as `setupLaunchAgentWithDynamicPlist`, kept here so the release code path
+    /// stays byte-for-byte upstream.
+    var debugPlistContent: String {
+        """
+        
+        
+        
+        
+            Label
+            \(serviceIdentifier)
+            Program
+            \(executableURL.path)
+            MachServices
+            
+                \(serviceIdentifier)
+                
+            
+            AssociatedBundleIdentifiers
+            
+                \(bundleIdentifier)
+                \(serviceIdentifier)
+            
+        
+        
+        """
+    }
+
+    func writeDebugPlist(_ content: String) throws {
+        try FileManager.default.createDirectory(
+            at: launchAgentDirURL,
+            withIntermediateDirectories: true
+        )
+        try content.write(toFile: launchAgentPath, atomically: true, encoding: .utf8)
+    }
+
+    func isDebugLaunchAgentLoaded() async -> Bool {
+        (try? await debugLaunchctl("print", serviceTarget)) != nil
+    }
+
+    func waitUntilDebugLaunchAgentUnloaded() async {
+        for _ in 0..<20 {
+            if await !isDebugLaunchAgentLoaded() { return }
+            try? await Task.sleep(nanoseconds: 100_000_000)
+        }
+    }
+
+    @discardableResult
+    func debugLaunchctl(_ arguments: String...) async throws -> String {
+        let task = Process()
+        task.executableURL = URL(fileURLWithPath: "/bin/launchctl")
+        task.arguments = arguments
+        task.environment = ["PATH": "/usr/bin:/bin"]
+        let pipe = Pipe()
+        task.standardOutput = pipe
+        task.standardError = pipe
+
+        return try await withCheckedThrowingContinuation { continuation in
+            task.terminationHandler = { process in
+                let data = (try? pipe.fileHandleForReading.readToEnd()) ?? Data()
+                let output = String(data: data, encoding: .utf8)?
+                    .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+                if process.terminationStatus == 0 {
+                    continuation.resume(returning: output)
+                } else {
+                    continuation.resume(throwing: DebugLaunchAgentError(
+                        command: "launchctl " + arguments.joined(separator: " "),
+                        status: process.terminationStatus,
+                        output: output
+                    ))
+                }
+            }
+            do {
+                try task.run()
+            } catch {
+                continuation.resume(throwing: error)
+            }
+        }
+    }
+}
+
+private struct DebugLaunchAgentError: Error, LocalizedError {
+    let command: String
+    let status: Int32
+    let output: String
+
+    var errorDescription: String? {
+        var message = "\(command) failed (\(status))"
+        if !output.isEmpty { message += ": \(output)" }
+        if output.contains("Input/output error") || status == 5 {
+            message += ". If the login item was disabled, enable it in System Settings › General › Login Items & Extensions."
+        }
+        return message
+    }
+}
+#endif
diff --git a/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift
index 3caf179b..a147ceb5 100644
--- a/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift
+++ b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift
@@ -36,6 +36,9 @@ public struct LaunchAgentManager {
     }
 
     public func setupLaunchAgent() async throws {
+        #if DEBUG
+        try await setupLaunchAgentForDebugBuild()
+        #else
         if #available(macOS 13, *) {
             if executableURL.path.hasPrefix("/Applications") {
                 try setupLaunchAgentWithPredefinedPlist()
@@ -49,6 +52,7 @@ public struct LaunchAgentManager {
         let buildNumber = (Bundle.main.infoDictionary?["CFBundleVersion"] as? String)
             .flatMap(Int.init)
         UserDefaults.standard.set(buildNumber, forKey: lastLaunchAgentVersionKey)
+        #endif
     }
 
     public func removeLaunchAgent() async throws {
diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift
index 801ce37f..84e52a56 100644
--- a/ExtensionService/AppDelegate.swift
+++ b/ExtensionService/AppDelegate.swift
@@ -100,10 +100,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
                     continue
                 }
                 Logger.service.info("Extension Service will quit.")
-                #if DEBUG
-                #else
                 quit()
-                #endif
             }
         }
     }

From aad96db1b27a7dcdaf0369e5dd66af8836171839 Mon Sep 17 00:00:00 2001
From: Skyler Liu <9743878+JayDaniel@users.noreply.github.com>
Date: Mon, 7 Sep 2026 07:16:54 -0700
Subject: [PATCH 07/16] Move the signing team to a local xcconfig

- Point Package.resolved at apple/swift-syntax.git, to match
  Tool/Package.swift
- Set all 12 DEVELOPMENT_TEAM settings to $(inherited) and read the
  real value from an optional Local.xcconfig
- Add Local.xcconfig.example and gitignore Local.xcconfig
- Drop the unused CodableWrappers dependency, and the unused Parsing
  import in RequestStrategy
---
 .gitignore                                    |  1 +
 Config.debug.xcconfig                         |  2 ++
 Config.xcconfig                               |  2 ++
 Copilot for Xcode.xcodeproj/project.pbxproj   | 24 +++++++++----------
 .../xcshareddata/swiftpm/Package.resolved     |  2 +-
 Local.xcconfig.example                        |  1 +
 Tool/Package.swift                            |  1 -
 .../RequestStrategy.swift                     |  1 -
 8 files changed, 19 insertions(+), 15 deletions(-)
 create mode 100644 Local.xcconfig.example

diff --git a/.gitignore b/.gitignore
index 488722ae..715afa87 100644
--- a/.gitignore
+++ b/.gitignore
@@ -128,6 +128,7 @@ iOSInjectionProject/
 https://www.toptal.com/developers/gitignore/api/xcode,macos,swift,swiftpackagemanager
 
 Secrets.xcconfig
+Local.xcconfig
 Python/Python.xcframework
 Python/python-stdlib
 Python/site-packages/*
diff --git a/Config.debug.xcconfig b/Config.debug.xcconfig
index 5417881b..33fde88c 100644
--- a/Config.debug.xcconfig
+++ b/Config.debug.xcconfig
@@ -11,3 +11,5 @@ EXTENSION_BUNDLE_DISPLAY_NAME = Copilot Dev
 EXTENSION_SERVICE_NAME = CopilotForXcodeExtensionService
 
 // see also target Configs
+
+#include? "Local.xcconfig"
diff --git a/Config.xcconfig b/Config.xcconfig
index 81d6e2ba..26b89e0d 100644
--- a/Config.xcconfig
+++ b/Config.xcconfig
@@ -11,3 +11,5 @@ EXTENSION_BUNDLE_DISPLAY_NAME = Copilot
 EXTENSION_SERVICE_NAME = CopilotForXcodeExtensionService
 
 // see also target Configs
+
+#include? "Local.xcconfig"
diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj
index fec097e7..99fd8790 100644
--- a/Copilot for Xcode.xcodeproj/project.pbxproj	
+++ b/Copilot for Xcode.xcodeproj/project.pbxproj	
@@ -776,7 +776,7 @@
 				COMBINE_HIDPI_IMAGES = YES;
 				CURRENT_PROJECT_VERSION = "$(APP_BUILD)";
 				DEAD_CODE_STRIPPING = YES;
-				DEVELOPMENT_TEAM = 94G4SCKS9Z;
+				DEVELOPMENT_TEAM = "$(inherited)";
 				ENABLE_HARDENED_RUNTIME = YES;
 				INFOPLIST_FILE = EditorExtension/Info.plist;
 				INFOPLIST_KEY_CFBundleDisplayName = "$(EXTENSION_BUNDLE_NAME)";
@@ -804,7 +804,7 @@
 				COMBINE_HIDPI_IMAGES = YES;
 				CURRENT_PROJECT_VERSION = "$(APP_BUILD)";
 				DEAD_CODE_STRIPPING = YES;
-				DEVELOPMENT_TEAM = 94G4SCKS9Z;
+				DEVELOPMENT_TEAM = "$(inherited)";
 				ENABLE_HARDENED_RUNTIME = YES;
 				INFOPLIST_FILE = EditorExtension/Info.plist;
 				INFOPLIST_KEY_CFBundleDisplayName = "$(EXTENSION_BUNDLE_NAME)";
@@ -955,7 +955,7 @@
 				CURRENT_PROJECT_VERSION = "$(APP_BUILD)";
 				DEAD_CODE_STRIPPING = YES;
 				DEVELOPMENT_ASSET_PATHS = "\"Copilot for Xcode/Preview Content\"";
-				DEVELOPMENT_TEAM = 94G4SCKS9Z;
+				DEVELOPMENT_TEAM = "$(inherited)";
 				ENABLE_HARDENED_RUNTIME = YES;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -989,7 +989,7 @@
 				CURRENT_PROJECT_VERSION = "$(APP_BUILD)";
 				DEAD_CODE_STRIPPING = YES;
 				DEVELOPMENT_ASSET_PATHS = "\"Copilot for Xcode/Preview Content\"";
-				DEVELOPMENT_TEAM = 94G4SCKS9Z;
+				DEVELOPMENT_TEAM = "$(inherited)";
 				ENABLE_HARDENED_RUNTIME = YES;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -1015,7 +1015,7 @@
 			buildSettings = {
 				CODE_SIGN_STYLE = Automatic;
 				DEAD_CODE_STRIPPING = YES;
-				DEVELOPMENT_TEAM = 94G4SCKS9Z;
+				DEVELOPMENT_TEAM = "$(inherited)";
 				ENABLE_HARDENED_RUNTIME = YES;
 				MACOSX_DEPLOYMENT_TARGET = 13.0;
 				PRODUCT_NAME = "$(TARGET_NAME)";
@@ -1029,7 +1029,7 @@
 			buildSettings = {
 				CODE_SIGN_STYLE = Automatic;
 				DEAD_CODE_STRIPPING = YES;
-				DEVELOPMENT_TEAM = 94G4SCKS9Z;
+				DEVELOPMENT_TEAM = "$(inherited)";
 				ENABLE_HARDENED_RUNTIME = YES;
 				MACOSX_DEPLOYMENT_TARGET = 13.0;
 				PRODUCT_NAME = "$(TARGET_NAME)";
@@ -1048,7 +1048,7 @@
 				COMBINE_HIDPI_IMAGES = YES;
 				CURRENT_PROJECT_VERSION = "$(APP_BUILD)";
 				DEAD_CODE_STRIPPING = YES;
-				DEVELOPMENT_TEAM = 94G4SCKS9Z;
+				DEVELOPMENT_TEAM = "$(inherited)";
 				ENABLE_HARDENED_RUNTIME = YES;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -1081,7 +1081,7 @@
 				COMBINE_HIDPI_IMAGES = YES;
 				CURRENT_PROJECT_VERSION = "$(APP_BUILD)";
 				DEAD_CODE_STRIPPING = YES;
-				DEVELOPMENT_TEAM = 94G4SCKS9Z;
+				DEVELOPMENT_TEAM = "$(inherited)";
 				ENABLE_HARDENED_RUNTIME = YES;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -1109,7 +1109,7 @@
 			buildSettings = {
 				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
 				CODE_SIGN_STYLE = Automatic;
-				DEVELOPMENT_TEAM = 94G4SCKS9Z;
+				DEVELOPMENT_TEAM = "$(inherited)";
 				ENABLE_HARDENED_RUNTIME = YES;
 				ENABLE_USER_SCRIPT_SANDBOXING = YES;
 				GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1128,7 +1128,7 @@
 			buildSettings = {
 				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
 				CODE_SIGN_STYLE = Automatic;
-				DEVELOPMENT_TEAM = 94G4SCKS9Z;
+				DEVELOPMENT_TEAM = "$(inherited)";
 				ENABLE_HARDENED_RUNTIME = YES;
 				ENABLE_USER_SCRIPT_SANDBOXING = YES;
 				GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1152,7 +1152,7 @@
 				COMBINE_HIDPI_IMAGES = YES;
 				CURRENT_PROJECT_VERSION = 1;
 				DEVELOPMENT_ASSET_PATHS = "\"SandboxedClientTester/Preview Content\"";
-				DEVELOPMENT_TEAM = 94G4SCKS9Z;
+				DEVELOPMENT_TEAM = "$(inherited)";
 				ENABLE_HARDENED_RUNTIME = YES;
 				ENABLE_PREVIEWS = YES;
 				ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1186,7 +1186,7 @@
 				COMBINE_HIDPI_IMAGES = YES;
 				CURRENT_PROJECT_VERSION = 1;
 				DEVELOPMENT_ASSET_PATHS = "\"SandboxedClientTester/Preview Content\"";
-				DEVELOPMENT_TEAM = 94G4SCKS9Z;
+				DEVELOPMENT_TEAM = "$(inherited)";
 				ENABLE_HARDENED_RUNTIME = YES;
 				ENABLE_PREVIEWS = YES;
 				ENABLE_USER_SCRIPT_SANDBOXING = YES;
diff --git a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 0c3d7e41..d048a5ab 100644
--- a/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved	
+++ b/Copilot for Xcode.xcworkspace/xcshareddata/swiftpm/Package.resolved	
@@ -291,7 +291,7 @@
     {
       "identity" : "swift-syntax",
       "kind" : "remoteSourceControl",
-      "location" : "https://github.com/swiftlang/swift-syntax",
+      "location" : "https://github.com/apple/swift-syntax.git",
       "state" : {
         "revision" : "0687f71944021d616d34d922343dcef086855920",
         "version" : "600.0.1"
diff --git a/Local.xcconfig.example b/Local.xcconfig.example
new file mode 100644
index 00000000..f7b09bc9
--- /dev/null
+++ b/Local.xcconfig.example
@@ -0,0 +1 @@
+DEVELOPMENT_TEAM = YOUR_TEAM_ID
diff --git a/Tool/Package.swift b/Tool/Package.swift
index 57ccc516..3b245506 100644
--- a/Tool/Package.swift
+++ b/Tool/Package.swift
@@ -474,7 +474,6 @@ let package = Package(
                 .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"),
                 .product(name: "GoogleGenerativeAI", package: "generative-ai-swift"),
                 .product(name: "Parsing", package: "swift-parsing"),
-                .product(name: "CodableWrappers", package: "CodableWrappers"),
             ]
         ),
         .testTarget(
diff --git a/Tool/Sources/CustomSuggestionService/RequestStrategy.swift b/Tool/Sources/CustomSuggestionService/RequestStrategy.swift
index a66c8b2d..9a27104d 100644
--- a/Tool/Sources/CustomSuggestionService/RequestStrategy.swift
+++ b/Tool/Sources/CustomSuggestionService/RequestStrategy.swift
@@ -1,7 +1,6 @@
 import CopilotForXcodeKit
 import Foundation
 import AIModel
-import Parsing
 
 /// Prompts may behave differently in different LLMs.
 /// This protocol allows for different strategies to be used to generate prompts.

From 01268ec791d6a973c6e82f05ae06cac8051590fb Mon Sep 17 00:00:00 2001
From: Skyler Liu <9743878+JayDaniel@users.noreply.github.com>
Date: Mon, 7 Sep 2026 17:09:47 -0700
Subject: [PATCH 08/16] Stop swallowing suggestion errors

Completion failures used to disappear into an erased stream, so a
misconfigured model looked exactly like a model with nothing to say.

- Drop eraseToStream and let getCompletion throw; Tabby and Gemini
  now use AsyncThrowingStream
- Parse a non-SSE response as one JSON body, report a non-200 as
  "HTTP : ", and stop turning a response that
  is not an HTTPURLResponse into a CancellationError
- Raise readable errors from the error.message line of
  chat/completions and from Anthropic type=error events
- Recognise the chat shape {"error":{"message":...}} before switching
  on the Responses event type, which used to hang
- Stop silently falling back on a dangling model id, clear
  customSuggestionModelId when its chat model is deleted, and give an
  empty id its own message
- Rate-limit repeated completion errors to silent within 30 seconds
  and notice otherwise; the presenter skips silent ones, and
  cancellation, URLError.cancelled included, is no longer logged as
  an error
- Map Completions chunks onto chat chunks, so the OpenAI completion
  path can share CompletionSequence
- Keep parsing Ollama application/x-ndjson line by line, since
  streaming NDJSON is not a single JSON document

The Gemini Google SDK session is not intercepted, so its tests cover
the same AsyncThrowingStream wrapper instead. Toasting a wrong API
key was not exercised by hand, this machine having no GUI for it.
---
 .../ChatModelManagement.swift                 |   3 +
 .../PresentInWindowSuggestionPresenter.swift  |   2 +
 .../API/AnthropicService.swift                |  93 +++++----
 .../API/AzureOpenAIService.swift              |  84 +++-----
 .../API/GoogleGeminiService.swift             |  16 +-
 .../API/MistralFIMService.swift               |  70 +++++--
 .../API/OllamaService.swift                   |  62 +++---
 .../API/OpenAIResponsesService.swift          |  34 ++-
 .../API/OpenAIService.swift                   | 152 ++++++++++----
 .../API/TabbyService.swift                    |  30 +--
 .../ChatAPISupport.swift                      |  82 +++++++-
 .../CodeCompletionLogger.swift                |   2 +
 .../CustomModelSuggestionService.swift        |  13 +-
 .../CustomSuggestionCoordinator.swift         |  40 +++-
 .../ResponseStream.swift                      |   7 +
 .../ChatAPIStreamParsingTests.swift           | 196 ++++++++++++++++++
 16 files changed, 656 insertions(+), 230 deletions(-)

diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift
index 64eadd57..f4117868 100644
--- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift
+++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift
@@ -87,6 +87,9 @@ struct ChatModelManagement: AIModelManagement {
 
             case let .removeModel(id):
                 state.models.remove(id: id)
+                if userDefaults.value(for: \.customSuggestionModelId) == id {
+                    userDefaults.set("", for: \.customSuggestionModelId)
+                }
                 persist(state)
                 return .none
 
diff --git a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift
index 7069422b..8b86a332 100644
--- a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift
+++ b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift
@@ -1,4 +1,5 @@
 import ChatService
+import enum CopilotForXcodeKit.SuggestionServiceError
 import Foundation
 import OpenAIService
 import SuggestionBasic
@@ -29,6 +30,7 @@ struct PresentInWindowSuggestionPresenter {
     func presentError(_ error: Error) {
         if error is CancellationError { return }
         if let urlError = error as? URLError, urlError.code == URLError.cancelled { return }
+        if case .silent? = error as? SuggestionServiceError { return }
         Task { @MainActor in
             let controller = Service.shared.guiController.widgetController
             controller.presentError(error.localizedDescription)
diff --git a/Tool/Sources/CustomSuggestionService/API/AnthropicService.swift b/Tool/Sources/CustomSuggestionService/API/AnthropicService.swift
index 04b1f612..c4609a9d 100644
--- a/Tool/Sources/CustomSuggestionService/API/AnthropicService.swift
+++ b/Tool/Sources/CustomSuggestionService/API/AnthropicService.swift
@@ -1,6 +1,7 @@
 import CopilotForXcodeKit
 import Foundation
 import AIModel
+import Logger
 
 public actor AnthropicService {
     let url: URL
@@ -51,13 +52,18 @@ public actor AnthropicService {
 // MARK: - CodeCompletionServiceType Implementation
 
 extension AnthropicService: CodeCompletionServiceType {
-    func getCompletion(_ request: PromptStrategy) async throws -> AsyncStream {
+    typealias CompletionSequence = AsyncThrowingCompactMapSequence<
+        ResponseStream,
+        String
+    >
+
+    func getCompletion(_ request: PromptStrategy) async throws -> CompletionSequence {
         let (messages, systemPrompt) = createMessages(from: request)
         CodeCompletionLogger.logger.logPrompt(messages.map {
             ($0.content, $0.role.rawValue)
         })
         let result = try await sendMessages(messages, systemPrompt: systemPrompt)
-        return result.compactMap { $0.delta?.text }.eraseToStream()
+        return result.compactMap { $0.delta?.text }
     }
 }
 
@@ -138,48 +144,57 @@ extension AnthropicService {
         request.httpBody = try encoder.encode(requestBody)
 
         let (result, response) = try await URLSession.shared.bytes(for: request)
+        return try await CompletionHTTP.makeStream(
+            result: result,
+            response: response,
+            mapError: { Error.otherError($0) },
+            parseBody: { try Self.parseMessageBody($0, url: url) },
+            parseLine: Self.parseStreamLine
+        )
+    }
 
-        guard let httpResponse = response as? HTTPURLResponse else {
-            throw CancellationError()
+    static func parseMessageBody(_ text: String, url: URL) throws -> StreamResponse {
+        if let message = APIErrorPayload.message(in: text) {
+            throw Error.apiError(APIError(type: "error", message: message, code: nil))
         }
-
-        guard httpResponse.statusCode == 200 else {
-            let text = try await result.lines.reduce(into: "") { partialResult, current in
-                partialResult += current
-            }
-            throw Error.otherError(text)
+        do {
+            let body = try JSONDecoder().decode(StreamResponse.self, from: Data(text.utf8))
+            let joined = (body.content ?? []).map(\.text).joined()
+            return StreamResponse(
+                type: body.type ?? "message",
+                delta: .init(text: joined, type: "text"),
+                index: nil,
+                content: body.content
+            )
+        } catch let error as Error {
+            throw error
+        } catch {
+            throw Error.otherError(CompletionHTTP.parseFailureMessage(url: url))
         }
+    }
 
-        return ResponseStream(result: result) {
-            var text = $0
-
-            if text.hasPrefix("event: ") {
-                return .init(chunk: StreamResponse(), done: false)
-            }
-
-            if text.hasPrefix("data: ") {
-                text = String(text.dropFirst(6))
-
-                guard !text.trimmingCharacters(in: .whitespaces).isEmpty else {
-                    return .init(chunk: StreamResponse(), done: false)
-                }
-
-                do {
-                    let chunk = try JSONDecoder().decode(
-                        StreamResponse.self,
-                        from: text.data(using: .utf8) ?? Data()
-                    )
-                    return .init(
-                        chunk: chunk,
-                        done: chunk.type == "message_stop"
-                    )
-                } catch {
-                    print("Error decoding chunk: \(error)")
-                    throw error
-                }
+    static func parseStreamLine(_ line: String) throws -> ResponseStream.LineContent {
+        guard let payload = SSELine.payload(of: line) else {
+            return .init(chunk: nil, done: false)
+        }
+        struct ErrorEvent: Decodable {
+            var type: String?
+            var error: APIError?
+        }
+        if let event = try? JSONDecoder().decode(ErrorEvent.self, from: Data(payload.utf8)),
+           event.type == "error"
+        {
+            if let error = event.error {
+                throw Error.apiError(error)
             }
-
-            return .init(chunk: StreamResponse(), done: false)
+            throw Error.otherError("Unknown error.")
+        }
+        do {
+            let chunk = try JSONDecoder().decode(StreamResponse.self, from: Data(payload.utf8))
+            return .init(chunk: chunk, done: chunk.type == "message_stop")
+        } catch {
+            Logger.service.error("Error decoding chunk: \(error)")
+            throw error
         }
     }
 }
diff --git a/Tool/Sources/CustomSuggestionService/API/AzureOpenAIService.swift b/Tool/Sources/CustomSuggestionService/API/AzureOpenAIService.swift
index a767275a..18c67780 100644
--- a/Tool/Sources/CustomSuggestionService/API/AzureOpenAIService.swift
+++ b/Tool/Sources/CustomSuggestionService/API/AzureOpenAIService.swift
@@ -42,7 +42,12 @@ public actor AzureOpenAIService {
 }
 
 extension AzureOpenAIService: CodeCompletionServiceType {
-    func getCompletion(_ request: PromptStrategy) async throws -> AsyncStream {
+    typealias CompletionSequence = AsyncThrowingCompactMapSequence<
+        ResponseStream,
+        String
+    >
+
+    func getCompletion(_ request: PromptStrategy) async throws -> CompletionSequence {
         switch endpoint {
         case .chatCompletion:
             let messages = createMessages(from: request)
@@ -50,12 +55,12 @@ extension AzureOpenAIService: CodeCompletionServiceType {
                 ($0.content, $0.role.rawValue)
             })
             let result = try await sendMessages(messages)
-            return result.compactMap { $0.choices?.first?.delta?.content }.eraseToStream()
+            return result.compactMap { $0.choices?.first?.delta?.content }
         case .completion:
             let prompt = createPrompt(from: request)
             CodeCompletionLogger.logger.logPrompt([(prompt, "user")])
             let result = try await sendPrompt(prompt)
-            return result.compactMap { $0.choices?.first?.text }.eraseToStream()
+            return result.compactMap { $0.choices?.first?.delta?.content }
         }
     }
 }
@@ -102,34 +107,13 @@ extension AzureOpenAIService {
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
         request.setValue(apiKey, forHTTPHeaderField: "api-key")
         let (result, response) = try await URLSession.shared.bytes(for: request)
-
-        guard let response = response as? HTTPURLResponse else {
-            throw CancellationError()
-        }
-
-        guard response.statusCode == 200 else {
-            let text = try await result.lines.reduce(into: "") { partialResult, current in
-                partialResult += current
-            }
-            throw Error.otherError(text)
-        }
-
-        return ResponseStream(result: result) {
-            var text = $0
-            if text.hasPrefix("data: ") {
-                text = String(text.dropFirst(6))
-            }
-            do {
-                let chunk = try JSONDecoder().decode(
-                    OpenAIService.ChatCompletionsStreamDataChunk.self,
-                    from: text.data(using: .utf8) ?? Data()
-                )
-                return .init(chunk: chunk, done: chunk.choices?.first?.finish_reason != nil)
-            } catch {
-                print(error)
-                throw error
-            }
-        }
+        return try await CompletionHTTP.makeStream(
+            result: result,
+            response: response,
+            mapError: { Error.otherError($0) },
+            parseBody: { try OpenAIService.parseChatCompletionsBody($0, url: url) },
+            parseLine: OpenAIService.parseChatCompletionsStreamLine
+        )
     }
 
     func createPrompt(from request: PromptStrategy) -> String {
@@ -143,7 +127,7 @@ extension AzureOpenAIService {
 
     func sendPrompt(
         _ prompt: String
-    ) async throws -> ResponseStream {
+    ) async throws -> ResponseStream {
         let requestBody = OpenAIService.CompletionRequestBody(
             model: modelName,
             prompt: prompt,
@@ -160,34 +144,16 @@ extension AzureOpenAIService {
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
         request.setValue(apiKey, forHTTPHeaderField: "api-key")
         let (result, response) = try await URLSession.shared.bytes(for: request)
-
-        guard let response = response as? HTTPURLResponse else {
-            throw CancellationError()
-        }
-
-        guard response.statusCode == 200 else {
-            let text = try await result.lines.reduce(into: "") { partialResult, current in
-                partialResult += current
-            }
-            throw Error.otherError(text)
-        }
-
-        return ResponseStream(result: result) {
-            var text = $0
-            if text.hasPrefix("data: ") {
-                text = String(text.dropFirst(6))
-            }
-            do {
-                let chunk = try JSONDecoder().decode(
-                    OpenAIService.CompletionsStreamDataChunk.self,
-                    from: text.data(using: .utf8) ?? Data()
-                )
-                return .init(chunk: chunk, done: chunk.choices?.first?.finish_reason != nil)
-            } catch {
-                print(error)
-                throw error
+        return try await CompletionHTTP.makeStream(
+            result: result,
+            response: response,
+            mapError: { Error.otherError($0) },
+            parseBody: { try OpenAIService.asChatChunk(OpenAIService.parseCompletionsBody($0, url: url)) },
+            parseLine: { line in
+                let content = try OpenAIService.parseCompletionsStreamLine(line)
+                return .init(chunk: content.chunk.map(OpenAIService.asChatChunk), done: content.done)
             }
-        }
+        )
     }
 
     func countToken(_ message: Message) -> Int {
diff --git a/Tool/Sources/CustomSuggestionService/API/GoogleGeminiService.swift b/Tool/Sources/CustomSuggestionService/API/GoogleGeminiService.swift
index 63ab980e..28e33617 100644
--- a/Tool/Sources/CustomSuggestionService/API/GoogleGeminiService.swift
+++ b/Tool/Sources/CustomSuggestionService/API/GoogleGeminiService.swift
@@ -28,17 +28,21 @@ public struct GoogleGeminiService {
 }
 
 extension GoogleGeminiService: CodeCompletionServiceType {
-    func getCompletion(_ request: PromptStrategy) async throws -> AsyncStream {
+    func getCompletion(_ request: PromptStrategy) async throws -> AsyncThrowingStream {
         let messages = createMessages(from: request)
         CodeCompletionLogger.logger.logPrompt(messages.map {
             ($0.parts.first?.text ?? "N/A", $0.role ?? "N/A")
         })
-        return AsyncStream { continuation in
+        return AsyncThrowingStream { continuation in
             let task = Task {
-                let result = try await sendMessages(messages)
-                try Task.checkCancellation()
-                continuation.yield(result)
-                continuation.finish()
+                do {
+                    let result = try await sendMessages(messages)
+                    try Task.checkCancellation()
+                    continuation.yield(result)
+                    continuation.finish()
+                } catch {
+                    continuation.finish(throwing: error)
+                }
             }
             continuation.onTermination = { _ in
                 task.cancel()
diff --git a/Tool/Sources/CustomSuggestionService/API/MistralFIMService.swift b/Tool/Sources/CustomSuggestionService/API/MistralFIMService.swift
index 9652f753..f9fa16ab 100644
--- a/Tool/Sources/CustomSuggestionService/API/MistralFIMService.swift
+++ b/Tool/Sources/CustomSuggestionService/API/MistralFIMService.swift
@@ -1,6 +1,7 @@
 import CopilotForXcodeKit
 import Foundation
 import AIModel
+import Logger
 
 public actor MistralFIMService {
     let url: URL
@@ -77,6 +78,7 @@ extension MistralFIMService {
             var delta: Delta?
             var text: String?
             var finish_reason: String?
+            var message: OpenAIService.Message?
         }
 
         var id: String?
@@ -115,33 +117,55 @@ extension MistralFIMService {
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
         request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
         let (result, response) = try await URLSession.shared.bytes(for: request)
-        
-        guard let response = response as? HTTPURLResponse else {
-            throw CancellationError()
-        }
+        return try await CompletionHTTP.makeStream(
+            result: result,
+            response: response,
+            mapError: { Error.otherError($0) },
+            parseBody: { try Self.parseBody($0, url: url) },
+            parseLine: Self.parseStreamLine
+        )
+    }
 
-        guard response.statusCode == 200 else {
-            let text = try await result.lines.reduce(into: "") { partialResult, current in
-                partialResult += current
+    static func parseBody(_ text: String, url: URL) throws -> StreamDataChunk {
+        if let message = APIErrorPayload.message(in: text) {
+            throw Error.otherError(message)
+        }
+        do {
+            var chunk = try JSONDecoder().decode(StreamDataChunk.self, from: Data(text.utf8))
+            if let choice = chunk.choices?.first,
+               choice.delta?.content == nil,
+               choice.text == nil,
+               let content = choice.message?.content
+            {
+                chunk.choices = [
+                    .init(
+                        index: choice.index,
+                        delta: .init(role: choice.message?.role, content: content),
+                        text: content,
+                        finish_reason: choice.finish_reason ?? "stop",
+                        message: choice.message
+                    ),
+                ]
             }
-            throw Error.otherError(text)
+            return chunk
+        } catch let error as Error {
+            throw error
+        } catch {
+            throw Error.otherError(CompletionHTTP.parseFailureMessage(url: url))
         }
+    }
 
-        return ResponseStream(result: result) {
-            var text = $0
-            if text.hasPrefix("data: ") {
-                text = String(text.dropFirst(6))
-            }
-            do {
-                let chunk = try JSONDecoder().decode(
-                    StreamDataChunk.self,
-                    from: text.data(using: .utf8) ?? Data()
-                )
-                return .init(chunk: chunk, done: chunk.choices?.first?.finish_reason != nil)
-            } catch {
-                print(error)
-                throw error
-            }
+    static func parseStreamLine(_ line: String) throws -> ResponseStream.LineContent {
+        guard let payload = SSELine.payload(of: line) else {
+            return .init(chunk: nil, done: false)
+        }
+        if payload == "[DONE]" { return .init(chunk: nil, done: true) }
+        do {
+            let chunk = try JSONDecoder().decode(StreamDataChunk.self, from: Data(payload.utf8))
+            return .init(chunk: chunk, done: chunk.choices?.first?.finish_reason != nil)
+        } catch {
+            Logger.service.error(error)
+            throw error
         }
     }
 }
diff --git a/Tool/Sources/CustomSuggestionService/API/OllamaService.swift b/Tool/Sources/CustomSuggestionService/API/OllamaService.swift
index 854e916e..d594ba0d 100644
--- a/Tool/Sources/CustomSuggestionService/API/OllamaService.swift
+++ b/Tool/Sources/CustomSuggestionService/API/OllamaService.swift
@@ -209,25 +209,29 @@ extension OllamaService {
         request.httpBody = try encoder.encode(requestBody)
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
         let (result, response) = try await URLSession.shared.bytes(for: request)
+        return try await CompletionHTTP.makeStream(
+            result: result,
+            response: response,
+            mapError: { Error.otherError($0) },
+            parseBody: { try Self.parseChunkBody($0, url: url) },
+            parseLine: Self.parseStreamLine
+        )
+    }
 
-        guard let response = response as? HTTPURLResponse else {
-            throw CancellationError()
-        }
-
-        guard response.statusCode == 200 else {
-            let text = try await result.lines.reduce(into: "") { partialResult, current in
-                partialResult += current
-            }
-            throw Error.otherError(text)
+    static func parseChunkBody(_ text: String, url: URL) throws -> ChatCompletionResponseChunk {
+        do {
+            return try JSONDecoder().decode(ChatCompletionResponseChunk.self, from: Data(text.utf8))
+        } catch {
+            throw Error.otherError(CompletionHTTP.parseFailureMessage(url: url))
         }
+    }
 
-        return ResponseStream(result: result) {
-            let chunk = try JSONDecoder().decode(
-                ChatCompletionResponseChunk.self,
-                from: $0.data(using: .utf8) ?? Data()
-            )
-            return .init(chunk: chunk, done: chunk.done)
-        }
+    static func parseStreamLine(_ line: String) throws -> ResponseStream.LineContent {
+        let chunk = try JSONDecoder().decode(
+            ChatCompletionResponseChunk.self,
+            from: line.data(using: .utf8) ?? Data()
+        )
+        return .init(chunk: chunk, done: chunk.done)
     }
 }
 
@@ -291,25 +295,13 @@ extension OllamaService {
         }
         
         let (result, response) = try await URLSession.shared.bytes(for: request)
-
-        guard let response = response as? HTTPURLResponse else {
-            throw CancellationError()
-        }
-
-        guard response.statusCode == 200 else {
-            let text = try await result.lines.reduce(into: "") { partialResult, current in
-                partialResult += current
-            }
-            throw Error.otherError(text)
-        }
-
-        return ResponseStream(result: result) {
-            let chunk = try JSONDecoder().decode(
-                ChatCompletionResponseChunk.self,
-                from: $0.data(using: .utf8) ?? Data()
-            )
-            return .init(chunk: chunk, done: chunk.done)
-        }
+        return try await CompletionHTTP.makeStream(
+            result: result,
+            response: response,
+            mapError: { Error.otherError($0) },
+            parseBody: { try Self.parseChunkBody($0, url: url) },
+            parseLine: Self.parseStreamLine
+        )
     }
 
     func countToken(_ message: Message) -> Int {
diff --git a/Tool/Sources/CustomSuggestionService/API/OpenAIResponsesService.swift b/Tool/Sources/CustomSuggestionService/API/OpenAIResponsesService.swift
index 7d16c1ee..0c34a7da 100644
--- a/Tool/Sources/CustomSuggestionService/API/OpenAIResponsesService.swift
+++ b/Tool/Sources/CustomSuggestionService/API/OpenAIResponsesService.swift
@@ -118,19 +118,30 @@ extension OpenAIResponsesService {
         request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
         request.setExtraHeaders(extraHeaders)
         let (result, response) = try await URLSession.shared.bytes(for: request)
+        return try await CompletionHTTP.makeStream(
+            result: result,
+            response: response,
+            mapError: { Error.otherError($0) },
+            parseBody: { try Self.parseNonStreamingBody($0, url: url) },
+            parseLine: Self.parseStreamLine
+        )
+    }
 
-        guard let response = response as? HTTPURLResponse else {
-            throw CancellationError()
+    static func parseNonStreamingBody(_ text: String, url: URL) throws -> StreamChunk {
+        if let message = APIErrorPayload.message(in: text) {
+            throw Error.apiError(message)
         }
-
-        guard response.statusCode == 200 else {
-            let text = try await result.lines.reduce(into: "") { partialResult, current in
-                partialResult += current
+        do {
+            let object = try JSONDecoder().decode(ResponseObject.self, from: Data(text.utf8))
+            if let message = object.error?.message {
+                throw Error.apiError(message)
             }
-            throw Error.otherError(text)
+            return StreamChunk(text: object.outputText)
+        } catch let error as Error {
+            throw error
+        } catch {
+            throw Error.otherError(CompletionHTTP.parseFailureMessage(url: url))
         }
-
-        return ResponseStream(result: result, lineExtractor: Self.parseStreamLine)
     }
 }
 
@@ -197,10 +208,13 @@ extension OpenAIResponsesService {
         }
     }
 
-    /// Parses one line of the event stream, or a whole non-streaming JSON body.
+    /// Parses one line of the event stream.
     static func parseStreamLine(_ line: String) throws -> ResponseStream.LineContent {
         guard let payload = SSELine.payload(of: line) else { return .init(chunk: nil, done: false) }
         if payload == "[DONE]" { return .init(chunk: nil, done: true) }
+        if let message = APIErrorPayload.message(in: payload) {
+            throw Error.apiError(message)
+        }
         let event = try JSONDecoder().decode(StreamEvent.self, from: Data(payload.utf8))
         switch event.type {
         case "response.output_text.delta":
diff --git a/Tool/Sources/CustomSuggestionService/API/OpenAIService.swift b/Tool/Sources/CustomSuggestionService/API/OpenAIService.swift
index afa74ba8..c6e60208 100644
--- a/Tool/Sources/CustomSuggestionService/API/OpenAIService.swift
+++ b/Tool/Sources/CustomSuggestionService/API/OpenAIService.swift
@@ -55,7 +55,12 @@ public actor OpenAIService {
 }
 
 extension OpenAIService: CodeCompletionServiceType {
-    func getCompletion(_ request: PromptStrategy) async throws -> AsyncStream {
+    typealias CompletionSequence = AsyncThrowingCompactMapSequence<
+        ResponseStream,
+        String
+    >
+
+    func getCompletion(_ request: PromptStrategy) async throws -> CompletionSequence {
         switch endpoint {
         case .chatCompletion:
             let messages = createMessages(from: request)
@@ -63,12 +68,12 @@ extension OpenAIService: CodeCompletionServiceType {
                 ($0.content, $0.role.rawValue)
             })
             let result = try await sendMessages(messages)
-            return result.compactMap { $0.choices?.first?.delta?.content }.eraseToStream()
+            return result.compactMap { $0.choices?.first?.delta?.content }
         case .completion:
             let prompt = createPrompt(from: request)
             CodeCompletionLogger.logger.logPrompt([(prompt, "user")])
             let result = try await sendPrompt(prompt)
-            return result.compactMap { $0.choices?.first?.text }.eraseToStream()
+            return result.compactMap { $0.choices?.first?.delta?.content }
         }
     }
 }
@@ -83,16 +88,14 @@ public extension OpenAIService {
 
     enum Error: Swift.Error, LocalizedError {
         case decodeError(Swift.Error)
-        case apiError(APIError)
+        case apiError(String)
         case otherError(String)
 
         public var errorDescription: String? {
             switch self {
             case let .decodeError(error):
                 return error.localizedDescription
-            case let .apiError(error):
-                return error.message
-            case let .otherError(message):
+            case let .apiError(message), let .otherError(message):
                 return message
             }
         }
@@ -160,27 +163,78 @@ extension OpenAIService {
         request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
         request.setExtraHeaders(extraHeaders)
         let (result, response) = try await URLSession.shared.bytes(for: request)
+        return try await CompletionHTTP.makeStream(
+            result: result,
+            response: response,
+            mapError: { Error.otherError($0) },
+            parseBody: { try Self.parseChatCompletionsBody($0, url: url) },
+            parseLine: Self.parseChatCompletionsStreamLine
+        )
+    }
 
-        guard let response = response as? HTTPURLResponse else {
-            throw CancellationError()
+    /// Parses a non-streaming `application/json` chat completions body.
+    static func parseChatCompletionsBody(
+        _ text: String,
+        url: URL
+    ) throws -> ChatCompletionsStreamDataChunk {
+        if let message = APIErrorPayload.message(in: text) {
+            throw Error.apiError(message)
         }
-
-        guard response.statusCode == 200 else {
-            let text = try await result.lines.reduce(into: "") { partialResult, current in
-                partialResult += current
-            }
-            throw Error.otherError(text)
+        do {
+            let body = try JSONDecoder().decode(
+                ChatCompletionResponseBody.self,
+                from: Data(text.utf8)
+            )
+            return chatChunk(from: body)
+        } catch let error as Error {
+            throw error
+        } catch {
+            throw Error.otherError(CompletionHTTP.parseFailureMessage(url: url))
         }
+    }
+
+    static func chatChunk(from body: ChatCompletionResponseBody) -> ChatCompletionsStreamDataChunk {
+        let choice = body.choices.first
+        return ChatCompletionsStreamDataChunk(
+            id: body.id,
+            object: body.object,
+            model: body.model,
+            choices: [
+                .init(
+                    delta: .init(role: choice?.message.role, content: choice?.message.content),
+                    index: choice?.index,
+                    finish_reason: choice?.finish_reason ?? "stop",
+                    message: choice?.message
+                ),
+            ]
+        )
+    }
 
-        return ResponseStream(result: result, lineExtractor: Self.parseChatCompletionsStreamLine)
+    static func asChatChunk(_ chunk: CompletionsStreamDataChunk) -> ChatCompletionsStreamDataChunk {
+        ChatCompletionsStreamDataChunk(
+            id: chunk.id,
+            object: chunk.object,
+            model: chunk.model,
+            choices: chunk.choices?.map { choice in
+                .init(
+                    delta: .init(role: nil, content: choice.text),
+                    index: choice.index,
+                    finish_reason: choice.finish_reason,
+                    message: nil
+                )
+            }
+        )
     }
 
-    /// Parses one line of the event stream, or a whole non-streaming JSON body.
+    /// Parses one line of the event stream.
     static func parseChatCompletionsStreamLine(
         _ line: String
     ) throws -> ResponseStream.LineContent {
         guard let payload = SSELine.payload(of: line) else { return .init(chunk: nil, done: false) }
         if payload == "[DONE]" { return .init(chunk: nil, done: true) }
+        if let message = APIErrorPayload.message(in: payload) {
+            throw Error.apiError(message)
+        }
         let chunk = try JSONDecoder().decode(
             ChatCompletionsStreamDataChunk.self,
             from: Data(payload.utf8)
@@ -252,7 +306,7 @@ extension OpenAIService {
         struct Choice: Codable, Equatable {
             var message: Message
             var index: Int
-            var finish_reason: String
+            var finish_reason: String?
         }
 
         struct Usage: Codable, Equatable {
@@ -262,9 +316,9 @@ extension OpenAIService {
         }
 
         var id: String?
-        var object: String
-        var model: String
-        var usage: Usage
+        var object: String?
+        var model: String?
+        var usage: Usage?
         var choices: [Choice]
     }
 
@@ -305,7 +359,7 @@ extension OpenAIService {
         return ([request.systemPrompt] + prompts.map(\.content)).joined(separator: "\n\n")
     }
 
-    func sendPrompt(_ prompt: String) async throws -> ResponseStream {
+    func sendPrompt(_ prompt: String) async throws -> ResponseStream {
         let requestBody = CompletionRequestBody(
             model: modelName,
             prompt: prompt,
@@ -323,27 +377,51 @@ extension OpenAIService {
         request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
         request.setExtraHeaders(extraHeaders)
         let (result, response) = try await URLSession.shared.bytes(for: request)
+        return try await CompletionHTTP.makeStream(
+            result: result,
+            response: response,
+            mapError: { Error.otherError($0) },
+            parseBody: { try Self.asChatChunk(Self.parseCompletionsBody($0, url: url)) },
+            parseLine: { line in
+                let content = try Self.parseCompletionsStreamLine(line)
+                return .init(chunk: content.chunk.map(Self.asChatChunk), done: content.done)
+            }
+        )
+    }
 
-        guard let response = response as? HTTPURLResponse else {
-            throw CancellationError()
+    static func parseCompletionsBody(
+        _ text: String,
+        url: URL
+    ) throws -> CompletionsStreamDataChunk {
+        if let message = APIErrorPayload.message(in: text) {
+            throw Error.apiError(message)
         }
-
-        guard response.statusCode == 200 else {
-            let text = try await result.lines.reduce(into: "") { partialResult, current in
-                partialResult += current
-            }
-            throw Error.otherError(text)
+        do {
+            let body = try JSONDecoder().decode(CompletionResponseBody.self, from: Data(text.utf8))
+            return CompletionsStreamDataChunk(
+                id: body.id,
+                object: body.object,
+                model: body.model,
+                choices: body.choices.map {
+                    .init(text: $0.text, index: $0.index, finish_reason: $0.finish_reason)
+                }
+            )
+        } catch let error as Error {
+            throw error
+        } catch {
+            throw Error.otherError(CompletionHTTP.parseFailureMessage(url: url))
         }
-
-        return ResponseStream(result: result, lineExtractor: Self.parseCompletionsStreamLine)
     }
 
-    /// Parses one line of the event stream, or a whole non-streaming JSON body.
+    /// Parses one line of the event stream.
     static func parseCompletionsStreamLine(
         _ line: String
     ) throws -> ResponseStream.LineContent {
         guard let payload = SSELine.payload(of: line) else { return .init(chunk: nil, done: false) }
         if payload == "[DONE]" { return .init(chunk: nil, done: true) }
+        if let message = APIErrorPayload.message(in: payload) {
+            throw Error.apiError(message)
+        }
         let chunk = try JSONDecoder().decode(CompletionsStreamDataChunk.self, from: Data(payload.utf8))
         return .init(chunk: chunk, done: chunk.choices?.first?.finish_reason != nil)
     }
@@ -394,7 +472,7 @@ extension OpenAIService {
         struct Choice: Codable, Equatable {
             var text: String
             var index: Int
-            var finish_reason: String
+            var finish_reason: String?
         }
 
         struct Usage: Codable, Equatable {
@@ -403,9 +481,9 @@ extension OpenAIService {
         }
 
         var id: String?
-        var object: String
-        var model: String
-        var usage: Usage
+        var object: String?
+        var model: String?
+        var usage: Usage?
         var choices: [Choice]
     }
 
diff --git a/Tool/Sources/CustomSuggestionService/API/TabbyService.swift b/Tool/Sources/CustomSuggestionService/API/TabbyService.swift
index 1c013073..87765e46 100644
--- a/Tool/Sources/CustomSuggestionService/API/TabbyService.swift
+++ b/Tool/Sources/CustomSuggestionService/API/TabbyService.swift
@@ -1,6 +1,7 @@
 import CopilotForXcodeKit
 import Foundation
 import AIModel
+import Logger
 
 actor TabbyService {
     enum AuthorizationMode {
@@ -27,7 +28,7 @@ actor TabbyService {
 }
 
 extension TabbyService: CodeCompletionServiceType {
-    func getCompletion(_ request: PromptStrategy) async throws -> AsyncStream {
+    func getCompletion(_ request: PromptStrategy) async throws -> AsyncThrowingStream {
         let prefix = request.prefix.joined()
         let suffix = request.suffix.joined()
         let requestBody = RequestBody(
@@ -44,12 +45,16 @@ extension TabbyService: CodeCompletionServiceType {
             (prefix, "prefix"),
             (suffix, "suffix"),
         ])
-        return AsyncStream { continuation in
+        return AsyncThrowingStream { continuation in
             let task = Task {
-                let result = try await send(requestBody)
-                try Task.checkCancellation()
-                continuation.yield(result)
-                continuation.finish()
+                do {
+                    let result = try await send(requestBody)
+                    try Task.checkCancellation()
+                    continuation.yield(result)
+                    continuation.finish()
+                } catch {
+                    continuation.finish(throwing: error)
+                }
             }
             continuation.onTermination = { _ in
                 task.cancel()
@@ -66,9 +71,9 @@ extension TabbyService {
         var errorDescription: String? {
             switch self {
             case let .serverError(message):
-                return "Server returned an error: \(message)"
+                return message
             case let .decodeError(error):
-                return "Failed to decode response body: \(error)"
+                return error.localizedDescription
             }
         }
     }
@@ -119,19 +124,20 @@ extension TabbyService {
         let (result, response) = try await URLSession.shared.data(for: request)
 
         guard let response = response as? HTTPURLResponse else {
-            throw CancellationError()
+            throw Error.serverError(CompletionHTTP.invalidResponseMessage())
         }
 
         guard response.statusCode == 200 else {
-            throw Error.serverError(String(data: result, encoding: .utf8) ?? "Unknown Error")
+            let text = String(data: result, encoding: .utf8) ?? ""
+            throw Error.serverError(CompletionHTTP.statusMessage(code: response.statusCode, body: text))
         }
 
         do {
             let body = try JSONDecoder().decode(ResponseBody.self, from: result)
             return body.choices.first?.text ?? ""
         } catch {
-            dump(error)
-            throw Error.decodeError(error)
+            Logger.service.error(error)
+            throw Error.serverError(CompletionHTTP.parseFailureMessage(url: url))
         }
     }
 }
diff --git a/Tool/Sources/CustomSuggestionService/ChatAPISupport.swift b/Tool/Sources/CustomSuggestionService/ChatAPISupport.swift
index 637d6e87..570ffe40 100644
--- a/Tool/Sources/CustomSuggestionService/ChatAPISupport.swift
+++ b/Tool/Sources/CustomSuggestionService/ChatAPISupport.swift
@@ -70,9 +70,6 @@ extension URLRequest {
 enum SSELine {
     /// The payload of a stream line, or nil for lines that carry no data
     /// (blank lines, `event:` / `id:` / `retry:` lines and comments).
-    ///
-    /// A line without the `data:` prefix is returned as is, so a non-streaming JSON body can be
-    /// handled by the same parser.
     static func payload(of line: String) -> String? {
         let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
         if trimmed.isEmpty { return nil }
@@ -85,3 +82,82 @@ enum SSELine {
         return trimmed
     }
 }
+
+/// Shared HTTP handling for completion `URLSession.bytes` calls.
+enum CompletionHTTP {
+    static func collectBody(_ bytes: URLSession.AsyncBytes) async throws -> String {
+        try await bytes.lines.reduce(into: "") { partial, line in
+            if !partial.isEmpty { partial += "\n" }
+            partial += line
+        }
+    }
+
+    static func isEventStream(_ response: HTTPURLResponse) -> Bool {
+        contentType(response).localizedCaseInsensitiveContains("text/event-stream")
+    }
+
+    static func isNDJSON(_ response: HTTPURLResponse) -> Bool {
+        contentType(response).localizedCaseInsensitiveContains("ndjson")
+    }
+
+    /// Pretty-printed JSON is multiple lines and must not go through `ResponseStream`.
+    /// Ollama streams NDJSON, which is also line-based.
+    static func shouldParseAsSingleBody(_ response: HTTPURLResponse) -> Bool {
+        !isEventStream(response) && !isNDJSON(response)
+    }
+
+    static func statusMessage(code: Int, body: String) -> String {
+        "HTTP \(code): \(String(body.prefix(200)))"
+    }
+
+    static func parseFailureMessage(url: URL) -> String {
+        "Cannot parse response from \(url.absoluteString)"
+    }
+
+    static func invalidResponseMessage() -> String {
+        "The server returned an invalid response."
+    }
+
+    static func makeStream(
+        result: URLSession.AsyncBytes,
+        response: URLResponse,
+        mapError: (String) -> Swift.Error,
+        parseBody: (String) throws -> Chunk,
+        parseLine: @escaping (String) throws -> ResponseStream.LineContent
+    ) async throws -> ResponseStream {
+        guard let http = response as? HTTPURLResponse else {
+            throw mapError(invalidResponseMessage())
+        }
+        if http.statusCode != 200 {
+            let body = try await collectBody(result)
+            throw mapError(statusMessage(code: http.statusCode, body: body))
+        }
+        if shouldParseAsSingleBody(http) {
+            let body = try await collectBody(result)
+            return ResponseStream(single: try parseBody(body))
+        }
+        return ResponseStream(result: result, lineExtractor: parseLine)
+    }
+
+    private static func contentType(_ response: HTTPURLResponse) -> String {
+        response.value(forHTTPHeaderField: "Content-Type") ?? ""
+    }
+}
+
+enum APIErrorPayload {
+    struct Envelope: Decodable {
+        struct Body: Decodable {
+            var message: String
+            var type: String?
+            var code: String?
+        }
+
+        var error: Body
+    }
+
+    static func message(in payload: String) -> String? {
+        guard let envelope = try? JSONDecoder().decode(Envelope.self, from: Data(payload.utf8))
+        else { return nil }
+        return envelope.error.message
+    }
+}
diff --git a/Tool/Sources/CustomSuggestionService/CodeCompletionLogger.swift b/Tool/Sources/CustomSuggestionService/CodeCompletionLogger.swift
index dfb11776..1a2f95fd 100644
--- a/Tool/Sources/CustomSuggestionService/CodeCompletionLogger.swift
+++ b/Tool/Sources/CustomSuggestionService/CodeCompletionLogger.swift
@@ -89,6 +89,8 @@ public final class CodeCompletionLogger {
     }
 
     public func error(_ error: Error) {
+        if error is CancellationError { return }
+        if let urlError = error as? URLError, urlError.code == .cancelled { return }
         guard shouldLogToConsole else { return }
 
         let now = Date()
diff --git a/Tool/Sources/CustomSuggestionService/CustomModelSuggestionService.swift b/Tool/Sources/CustomSuggestionService/CustomModelSuggestionService.swift
index be2db160..668bf6a6 100644
--- a/Tool/Sources/CustomSuggestionService/CustomModelSuggestionService.swift
+++ b/Tool/Sources/CustomSuggestionService/CustomModelSuggestionService.swift
@@ -1,4 +1,5 @@
 import CopilotForXcodeKit
+import enum CopilotForXcodeKit.SuggestionServiceError
 import Foundation
 import AIModel
 
@@ -27,7 +28,17 @@ public final class CustomModelSuggestionService: SuggestionServiceType {
         _ request: SuggestionRequest,
         workspace: WorkspaceInfo
     ) async throws -> [CodeSuggestion] {
-        try await service.getSuggestions(request, workspace: workspace)
+        do {
+            return try await service.getSuggestions(request, workspace: workspace)
+        } catch is CancellationError {
+            return []
+        } catch let error as URLError where error.code == .cancelled {
+            return []
+        } catch let error as SuggestionServiceError {
+            throw error
+        } catch {
+            throw SuggestionServiceError.notice(error)
+        }
     }
 }
 
diff --git a/Tool/Sources/CustomSuggestionService/CustomSuggestionCoordinator.swift b/Tool/Sources/CustomSuggestionService/CustomSuggestionCoordinator.swift
index 06ff80ec..ce2aebae 100644
--- a/Tool/Sources/CustomSuggestionService/CustomSuggestionCoordinator.swift
+++ b/Tool/Sources/CustomSuggestionService/CustomSuggestionCoordinator.swift
@@ -1,4 +1,5 @@
 import CopilotForXcodeKit
+import enum CopilotForXcodeKit.SuggestionServiceError
 import Foundation
 import Preferences
 import AIModel
@@ -11,7 +12,22 @@ actor CustomSuggestionCoordinator {
         case fimModel(FIMModel)
     }
 
+    enum SelectionError: Swift.Error, LocalizedError {
+        case modelMissing(String)
+
+        var errorDescription: String? {
+            switch self {
+            case let .modelMissing(id):
+                if id.isEmpty {
+                    return "No suggestion model is selected. Pick one in Service › Custom Model."
+                }
+                return "Selected suggestion model \"\(id)\" no longer exists. Pick one in Service › Custom Model."
+            }
+        }
+    }
+
     var onGoingTask: Task<[CodeSuggestion], Error>?
+    var lastNoticeDate: Date?
 
     func cancelRequest() {
         onGoingTask?.cancel()
@@ -40,11 +56,11 @@ actor CustomSuggestionCoordinator {
                     )
 
                     if strategy.shouldSkip {
-                        throw CancellationError()
+                        return [CodeSuggestion]()
                     }
 
                     let service = CodeCompletionService()
-                    let model = getModel()
+                    let model = try getModel()
                     let prompt = strategy.createPrompt()
                     let postProcessor = strategy.createRawSuggestionPostProcessor()
                     let stopStream = strategy.createStreamStopStrategy(model: model)
@@ -118,7 +134,7 @@ actor CustomSuggestionCoordinator {
                         }
                 } catch {
                     CodeCompletionLogger.logger.error(error)
-                    throw error
+                    throw mapUserFacingError(error)
                 }
             }
         }
@@ -126,13 +142,15 @@ actor CustomSuggestionCoordinator {
         return try await task.value
     }
 
-    func getModel() -> Model {
+    func getModel() throws -> Model {
         let id = UserDefaults.shared.value(for: \.customSuggestionModelId)
         let models = UserDefaults.shared.value(for: \.chatModels)
         if let existedModel = models.first(where: { $0.id == id }) {
             return .chatModel(existedModel)
         }
-        let type = CustomModelType(rawValue: id) ?? .default
+        guard let type = CustomModelType(rawValue: id) else {
+            throw SelectionError.modelMissing(id)
+        }
         switch type {
         case .completionModel:
             return .completionModel(UserDefaults.shared.value(for: \.customSuggestionCompletionModel))
@@ -143,6 +161,18 @@ actor CustomSuggestionCoordinator {
         }
     }
 
+    func mapUserFacingError(_ error: Swift.Error) -> Swift.Error {
+        if error is CancellationError { return error }
+        if let urlError = error as? URLError, urlError.code == .cancelled { return error }
+        if error is SuggestionServiceError { return error }
+        let now = Date()
+        if let lastNoticeDate, now.timeIntervalSince(lastNoticeDate) < 30 {
+            return SuggestionServiceError.silent(error)
+        }
+        lastNoticeDate = now
+        return SuggestionServiceError.notice(error)
+    }
+
     func getStrategy(
         sourceRequest: SuggestionRequest,
         prefix: [String],
diff --git a/Tool/Sources/CustomSuggestionService/ResponseStream.swift b/Tool/Sources/CustomSuggestionService/ResponseStream.swift
index ce28b7f9..82a4b37d 100644
--- a/Tool/Sources/CustomSuggestionService/ResponseStream.swift
+++ b/Tool/Sources/CustomSuggestionService/ResponseStream.swift
@@ -16,6 +16,13 @@ struct ResponseStream: AsyncSequence {
 
     let stream: Stream
 
+    init(single chunk: Chunk) {
+        stream = AsyncThrowingStream { continuation in
+            continuation.yield(chunk)
+            continuation.finish()
+        }
+    }
+
     init(result: URLSession.AsyncBytes, lineExtractor: @escaping (String) throws -> LineContent) {
         stream = AsyncThrowingStream { continuation in
             let task = Task {
diff --git a/Tool/Tests/CustomSuggestionServiceTests/ChatAPIStreamParsingTests.swift b/Tool/Tests/CustomSuggestionServiceTests/ChatAPIStreamParsingTests.swift
index df913f08..e789f5cd 100644
--- a/Tool/Tests/CustomSuggestionServiceTests/ChatAPIStreamParsingTests.swift
+++ b/Tool/Tests/CustomSuggestionServiceTests/ChatAPIStreamParsingTests.swift
@@ -1,3 +1,4 @@
+import CopilotForXcodeKit
 import Foundation
 import XCTest
 
@@ -42,6 +43,9 @@ class ChatAPIStreamParsingTests: XCTestCase {
         XCTAssertThrowsError(try OpenAIResponsesService.parseStreamLine(
             #"data: {"type":"response.failed","response":{"object":"response","status":"failed","error":{"message":"boom"}}}"#
         )) { XCTAssertEqual($0.localizedDescription, "boom") }
+        XCTAssertThrowsError(try OpenAIResponsesService.parseStreamLine(
+            #"data: {"error":{"message":"boom"}}"#
+        )) { XCTAssertEqual($0.localizedDescription, "boom") }
     }
 
     func test_responses_non_streaming_body() throws {
@@ -98,4 +102,196 @@ class ChatAPIStreamParsingTests: XCTestCase {
             "Bearer sk-1 for gpt-5.5"
         )
     }
+
+    func test_chatCompletions_error_line_throws_apiError() {
+        XCTAssertThrowsError(try OpenAIService.parseChatCompletionsStreamLine(
+            #"data: {"error":{"message":"boom"}}"#
+        )) { error in
+            XCTAssertEqual(error.localizedDescription, "boom")
+            guard case OpenAIService.Error.apiError(let message) = error else {
+                return XCTFail("expected apiError, got \(error)")
+            }
+            XCTAssertEqual(message, "boom")
+        }
+    }
+
+    func test_chatCompletions_pretty_printed_json_body_yields_message() async throws {
+        let body = """
+        {
+          "id": "chatcmpl-abc123",
+          "object": "chat.completion",
+          "model": "gpt-5.5",
+          "choices": [
+            {
+              "index": 0,
+              "message": {
+                "role": "assistant",
+                "content": "Hello! How can I help you today?"
+              },
+              "finish_reason": "stop"
+            }
+          ]
+        }
+        """
+        let parsed = try OpenAIService.parseChatCompletionsBody(
+            body,
+            url: URL(string: "https://api.example.invalid/v1/chat/completions")!
+        )
+        XCTAssertEqual(
+            parsed.choices?.first?.delta?.content,
+            "Hello! How can I help you today?"
+        )
+
+        let host = "wp2-openai-\(UUID().uuidString).invalid"
+        CompletionStubURLProtocol.host = host
+        CompletionStubURLProtocol.handler = { _ in
+            (200, ["Content-Type": "application/json"], Data(body.utf8))
+        }
+        URLProtocol.registerClass(CompletionStubURLProtocol.self)
+        defer { CompletionStubURLProtocol.reset() }
+
+        let service = OpenAIService(
+            url: "http://\(host)/v1/chat/completions",
+            endpoint: .chatCompletion,
+            modelName: "gpt-5.5",
+            contextWindow: 4096,
+            maxToken: 64,
+            apiKey: "sk-test"
+        )
+        let didFinish = expectation(description: "openai json body")
+        let task = Task {
+            do {
+                let stream = try await service.getCompletion(ErrorVisibilityPrompt())
+                var text = ""
+                for try await part in stream {
+                    text += part
+                }
+                XCTAssertEqual(text, "Hello! How can I help you today?")
+            } catch {
+                XCTFail("unexpected error: \(error)")
+            }
+            didFinish.fulfill()
+        }
+        await fulfillment(of: [didFinish], timeout: 2)
+        task.cancel()
+    }
+
+    func test_tabby_http_500_throws_immediately() async {
+        let host = "wp2-tabby-\(UUID().uuidString).invalid"
+        CompletionStubURLProtocol.host = host
+        CompletionStubURLProtocol.handler = { _ in
+            (500, ["Content-Type": "application/json"], Data("{\"error\":\"nope\"}".utf8))
+        }
+        URLProtocol.registerClass(CompletionStubURLProtocol.self)
+        defer { CompletionStubURLProtocol.reset() }
+
+        let service = TabbyService(
+            url: "http://\(host)/v1/completions",
+            authorizationMode: .none
+        )
+        let didThrow = expectation(description: "tabby throws")
+        let task = Task {
+            do {
+                let stream = try await service.getCompletion(ErrorVisibilityPrompt())
+                for try await _ in stream {
+                    XCTFail("should not yield")
+                }
+                XCTFail("should throw")
+            } catch {
+                XCTAssertTrue(
+                    error.localizedDescription.contains("HTTP 500"),
+                    error.localizedDescription
+                )
+                didThrow.fulfill()
+            }
+        }
+        await fulfillment(of: [didThrow], timeout: 2)
+        task.cancel()
+    }
+
+    func test_asyncThrowingStream_wrapper_surfaces_task_error() async {
+        struct Boom: Error {}
+        let stream = AsyncThrowingStream { continuation in
+            let task = Task {
+                do {
+                    throw Boom()
+                } catch {
+                    continuation.finish(throwing: error)
+                }
+            }
+            continuation.onTermination = { _ in task.cancel() }
+        }
+        do {
+            for try await _ in stream {
+                XCTFail("should not yield")
+            }
+            XCTFail("should throw")
+        } catch is Boom {
+            // Gemini's URLSession is owned by GoogleGenerativeAI and was not intercepted.
+        } catch {
+            XCTFail("unexpected \(error)")
+        }
+    }
+}
+
+private struct ErrorVisibilityPrompt: PromptStrategy {
+    var systemPrompt: String { "complete" }
+    var prefix: [String] { ["let x = "] }
+    var suffix: [String] { [] }
+    var relevantCodeSnippets: [RelevantCodeSnippet] { [] }
+    var stopWords: [String] { [] }
+    var language: CodeLanguage? { nil }
+
+    func createPrompt(
+        truncatedPrefix: [String],
+        truncatedSuffix: [String],
+        includedSnippets: [RelevantCodeSnippet]
+    ) -> [PromptMessage] {
+        [.init(role: .user, content: truncatedPrefix.joined())]
+    }
+}
+
+private final class CompletionStubURLProtocol: URLProtocol {
+    static var host: String?
+    static var handler: ((URLRequest) throws -> (Int, [String: String], Data))?
+
+    static func reset() {
+        URLProtocol.unregisterClass(CompletionStubURLProtocol.self)
+        host = nil
+        handler = nil
+    }
+
+    override class func canInit(with request: URLRequest) -> Bool {
+        request.url?.host == host
+    }
+
+    override class func canInit(with task: URLSessionTask) -> Bool {
+        guard let request = task.currentRequest else { return false }
+        return canInit(with: request)
+    }
+
+    override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
+
+    override func startLoading() {
+        guard let handler = Self.handler, let url = request.url else {
+            client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
+            return
+        }
+        do {
+            let (status, headers, data) = try handler(request)
+            let response = HTTPURLResponse(
+                url: url,
+                statusCode: status,
+                httpVersion: "HTTP/1.1",
+                headerFields: headers
+            )!
+            client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+            client?.urlProtocol(self, didLoad: data)
+            client?.urlProtocolDidFinishLoading(self)
+        } catch {
+            client?.urlProtocol(self, didFailWithError: error)
+        }
+    }
+
+    override func stopLoading() {}
 }

From f510fcaa500c4d9e90a4051c776056974eafd0c8 Mon Sep 17 00:00:00 2001
From: Skyler Liu <9743878+JayDaniel@users.noreply.github.com>
Date: Mon, 7 Sep 2026 17:53:53 -0700
Subject: [PATCH 09/16] Store chat API options per model

- Move OpenAIChatAPI and ReasoningEffort into Preferences, key the API
  options by ChatModel.id, xhigh and the reasoning token budget
  included, and migrate the old global keys once
- Treat a Responses endpoint as a full URL only for openAICompatible
  with isFullURL; strip the trailing slash and ignore the query before
  swapping /chat/completions for /responses
- Always truncate on stop words client side; with max lines at 0, stop
  words become the only limit. Add the budget to max_*_tokens for low,
  medium, high and xhigh
- Send store:false on Responses requests, and temperature:0.2 when no
  effort is set; send output_config.effort to Anthropic, mapping none
  and minimal to low, and xhigh to high
- Route headers through HeaderValueParser, validating names and
  newlines; forward extraHeaders to Azure and Ollama; merge customBody
  with JoinJSON; align org and project, the Gemini base URL and the
  message order with the host
- Show GitHub Copilot models in the picker as needing another choice,
  and throw from the completion path
- Show the reasoning token budget only for OpenAI and compatible

CustomSuggestionServiceTests passes with 54 cases. The gateway itself
was not verified by hand, this machine having neither a GUI nor
gateway credentials.
---
 .../CustomModelSuggestionView.swift           | 103 +++++--
 Tool/Package.swift                            |   4 +-
 .../API/AnthropicService.swift                |  65 +++-
 .../API/AzureOpenAIService.swift              |   7 +-
 .../API/GoogleGeminiService.swift             |  14 +-
 .../API/OllamaService.swift                   |   7 +-
 .../API/OpenAIResponsesService.swift          | 119 +++++++-
 .../API/OpenAIService.swift                   | 105 ++++++-
 .../ChatAPISupport.swift                      | 204 ++++++++++---
 .../CodeCompletionService.swift               | 117 ++++++--
 .../CustomSuggestionChatModelAPIOptions.swift | 111 +++++++
 .../CustomSuggestionServiceKeys.swift         |  16 +-
 .../ChatAPIOptionsAndSemanticsTests.swift     | 277 ++++++++++++++++++
 .../ChatAPIStreamParsingTests.swift           |  38 ++-
 14 files changed, 1050 insertions(+), 137 deletions(-)
 create mode 100644 Tool/Sources/Preferences/CustomSuggestionChatModelAPIOptions.swift
 create mode 100644 Tool/Tests/CustomSuggestionServiceTests/ChatAPIOptionsAndSemanticsTests.swift

diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift
index f1e7e0a4..0a7126f2 100644
--- a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift
+++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift
@@ -38,7 +38,7 @@ struct CustomModelSuggestionView: View {
                     }
 
                     NumberInput(value: settings.$maxLines, range: 0...Int.max, step: 1) {
-                        Text("Suggestion Line Limit (0 for unlimited)")
+                        Text("Suggestion Line Limit (0 means stop words only)")
                     }
 
                     NumberInput(
@@ -63,7 +63,7 @@ struct CustomModelSuggestionView: View {
                         Text("Chat Model API")
                     } footer: {
                         Text(
-                            "Applies to OpenAI and OpenAI compatible chat models. Setting a reasoning effort switches the request to the reasoning model shape: reasoning_effort is sent, max_completion_tokens replaces max_tokens, temperature and stop are omitted. Anthropic models always use /v1/messages."
+                            "Applies to the selected chat model. Setting a reasoning effort switches the request to the reasoning model shape: reasoning_effort is sent, max_completion_tokens replaces max_tokens, temperature and stop are omitted. Anthropic models always use /v1/messages. Headers sent to the gateway are only meaningful to the gateway itself and are not forwarded upstream."
                         )
                     }
                 }
@@ -111,10 +111,14 @@ struct CustomModelPicker: View {
     @StateObject var settings = Settings()
 
     var body: some View {
+        let selectedChatModel = settings.chatModels.first { $0.id == settings.modelId }
+        let isUnsupportedGitHubCopilot = selectedChatModel?.format == .gitHubCopilot
         let unknownId: String? =
-            if !settings.chatModels.contains(where: { $0.id == settings.modelId }),
-            !settings.modelId.isEmpty,
-            CustomModelType(rawValue: settings.modelId) == nil
+            if isUnsupportedGitHubCopilot {
+                settings.modelId
+            } else if !settings.modelId.isEmpty,
+            CustomModelType(rawValue: settings.modelId) == nil,
+            selectedChatModel == nil
         {
             settings.modelId
         } else {
@@ -123,7 +127,11 @@ struct CustomModelPicker: View {
 
         Picker(selection: settings.$modelId, label: Text("Model")) {
             if let unknownId {
-                Text("Unknown Model (Use Custom Model Instead)").tag(unknownId)
+                if isUnsupportedGitHubCopilot {
+                    Text("GitHub Copilot (pick another model)").tag(unknownId)
+                } else {
+                    Text("Unknown Model (Use Custom Model Instead)").tag(unknownId)
+                }
             }
 
             ForEach(CustomModelType.allCases, id: \.rawValue) {
@@ -137,7 +145,7 @@ struct CustomModelPicker: View {
                 }
             }
 
-            ForEach(settings.chatModels, id: \.id) { chatModel in
+            ForEach(settings.chatModels.filter { $0.format != .gitHubCopilot }, id: \.id) { chatModel in
                 Text(chatModel.name).tag(chatModel.id)
             }
         }
@@ -199,28 +207,83 @@ struct RequestStrategyPicker: View {
 
 struct ChatAPIOptionsPicker: View {
     final class Settings: ObservableObject {
-        @AppStorage(\.customSuggestionOpenAIAPI) var openAIAPI
-        @AppStorage(\.customSuggestionReasoningEffort) var reasoningEffort
+        @AppStorage(\.customSuggestionModelId) var modelId
+        @AppStorage(\.chatModels) var chatModels: [ChatModel]
     }
 
     @StateObject var settings = Settings()
 
+    private var selectedModel: ChatModel? {
+        settings.chatModels.first { $0.id == settings.modelId }
+    }
+
+    private var options: ChatModelAPIOptions {
+        ChatModelAPIOptions.stored(for: settings.modelId)
+    }
+
+    private func update(_ mutate: (inout ChatModelAPIOptions) -> Void) {
+        var current = options
+        mutate(¤t)
+        ChatModelAPIOptions.update(current, for: settings.modelId)
+        settings.objectWillChange.send()
+    }
+
     var body: some View {
-        Picker(selection: settings.$openAIAPI, label: Text("OpenAI Endpoint")) {
-            ForEach(OpenAIChatAPI.allCases, id: \.rawValue) { api in
-                switch api {
-                case .chatCompletions:
-                    Text("Chat Completions (/v1/chat/completions)").tag(api.rawValue)
-                case .responses:
-                    Text("Responses (/v1/responses)").tag(api.rawValue)
+        let format = selectedModel?.format
+        let currentOptions = options
+        let showsEndpoint = format == .openAI || format == .openAICompatible
+        let showsEffort = format == .openAI || format == .openAICompatible || format == .claude
+
+        if showsEndpoint {
+            Picker(
+                selection: Binding(
+                    get: { currentOptions.api.rawValue },
+                    set: { newValue in
+                        update { $0.api = OpenAIChatAPI(rawValue: newValue) ?? .chatCompletions }
+                    }
+                ),
+                label: Text("OpenAI Endpoint")
+            ) {
+                ForEach(OpenAIChatAPI.allCases, id: \.rawValue) { api in
+                    switch api {
+                    case .chatCompletions:
+                        Text("Chat Completions (/v1/chat/completions)").tag(api.rawValue)
+                    case .responses:
+                        Text("Responses (/v1/responses)").tag(api.rawValue)
+                    }
                 }
             }
         }
 
-        Picker(selection: settings.$reasoningEffort, label: Text("Reasoning Effort")) {
-            Text("Not Set").tag("")
-            ForEach(ReasoningEffort.allCases, id: \.rawValue) { effort in
-                Text(effort.rawValue.capitalized).tag(effort.rawValue)
+        if showsEffort {
+            Picker(
+                selection: Binding(
+                    get: { currentOptions.reasoningEffort?.rawValue ?? "" },
+                    set: { newValue in
+                        update { $0.reasoningEffort = ReasoningEffort(rawValue: newValue) }
+                    }
+                ),
+                label: Text("Reasoning Effort")
+            ) {
+                Text("Not Set").tag("")
+                ForEach(ReasoningEffort.allCases, id: \.rawValue) { effort in
+                    Text(effort.rawValue.capitalized).tag(effort.rawValue)
+                }
+            }
+        }
+
+        if showsEndpoint, currentOptions.reasoningEffort != nil {
+            NumberInput(
+                value: Binding(
+                    get: { currentOptions.reasoningTokenBudget },
+                    set: { newValue in
+                        update { $0.reasoningTokenBudget = newValue }
+                    }
+                ),
+                range: 0...Int.max,
+                step: 100
+            ) {
+                Text("Reasoning Token Budget")
             }
         }
     }
diff --git a/Tool/Package.swift b/Tool/Package.swift
index 3b245506..e369afe3 100644
--- a/Tool/Package.swift
+++ b/Tool/Package.swift
@@ -471,6 +471,8 @@ let package = Package(
                 "Keychain",
                 "Logger",
                 "BuiltinExtension",
+                "OpenAIService",
+                "JoinJSON",
                 .product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"),
                 .product(name: "GoogleGenerativeAI", package: "generative-ai-swift"),
                 .product(name: "Parsing", package: "swift-parsing"),
@@ -478,7 +480,7 @@ let package = Package(
         ),
         .testTarget(
             name: "CustomSuggestionServiceTests",
-            dependencies: ["CustomSuggestionService", "AIModel"]
+            dependencies: ["CustomSuggestionService", "AIModel", "Preferences", "JoinJSON"]
         ),
 
         // MARK: - OpenAI
diff --git a/Tool/Sources/CustomSuggestionService/API/AnthropicService.swift b/Tool/Sources/CustomSuggestionService/API/AnthropicService.swift
index c4609a9d..2b19d329 100644
--- a/Tool/Sources/CustomSuggestionService/API/AnthropicService.swift
+++ b/Tool/Sources/CustomSuggestionService/API/AnthropicService.swift
@@ -2,6 +2,7 @@ import CopilotForXcodeKit
 import Foundation
 import AIModel
 import Logger
+import Preferences
 
 public actor AnthropicService {
     let url: URL
@@ -12,6 +13,8 @@ public actor AnthropicService {
     let apiKey: String
     let stopWords: [String]
     let extraHeaders: [(name: String, value: String)]
+    let reasoningEffort: ReasoningEffort?
+    let customJSONBody: String
 
     init(
         url: String? = nil,
@@ -21,9 +24,13 @@ public actor AnthropicService {
         temperature: Double = 0.2,
         stopWords: [String] = [],
         apiKey: String,
-        extraHeaders: [(name: String, value: String)] = []
+        extraHeaders: [(name: String, value: String)] = [],
+        reasoningEffort: ReasoningEffort? = nil,
+        customJSONBody: String = ""
     ) {
         self.extraHeaders = extraHeaders
+        self.reasoningEffort = reasoningEffort
+        self.customJSONBody = customJSONBody
         self.url = url.flatMap(URL.init(string:)) ??
             URL(string: "https://api.anthropic.com/v1/messages")!
         self.modelName = modelName
@@ -80,6 +87,10 @@ extension AnthropicService {
         var content: String
     }
 
+    struct OutputConfig: Codable, Equatable {
+        var effort: String
+    }
+
     struct MessageRequestBody: Codable {
         var model: String
         var messages: [Message]
@@ -88,6 +99,7 @@ extension AnthropicService {
         var temperature: Double
         var stream: Bool = true
         var stop_sequences: [String]?
+        var output_config: OutputConfig?
 
         enum CodingKeys: String, CodingKey {
             case model
@@ -97,7 +109,42 @@ extension AnthropicService {
             case temperature
             case stream
             case stop_sequences
+            case output_config
+        }
+    }
+
+    nonisolated static func outputConfig(for effort: ReasoningEffort?) -> OutputConfig? {
+        guard let effort else { return nil }
+        let value: String
+        switch effort {
+        case .low, .medium, .high:
+            value = effort.rawValue
+        case .none, .minimal:
+            value = "low"
+        case .xhigh:
+            value = "high"
         }
+        return .init(effort: value)
+    }
+
+    nonisolated static func makeMessageRequestBody(
+        modelName: String,
+        messages: [Message],
+        systemPrompt: String?,
+        maxToken: Int,
+        temperature: Double,
+        stopSequences: [String]?,
+        reasoningEffort: ReasoningEffort?
+    ) -> MessageRequestBody {
+        MessageRequestBody(
+            model: modelName,
+            messages: messages,
+            system: systemPrompt,
+            max_tokens: maxToken,
+            temperature: temperature,
+            stop_sequences: stopSequences,
+            output_config: outputConfig(for: reasoningEffort)
+        )
     }
 
     func createMessages(from request: PromptStrategy) -> (messages: [Message], system: String?) {
@@ -124,13 +171,14 @@ extension AnthropicService {
             !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
         }
 
-        let requestBody = MessageRequestBody(
-            model: modelName,
+        let requestBody = Self.makeMessageRequestBody(
+            modelName: modelName,
             messages: messages,
-            system: systemPrompt,
-            max_tokens: maxToken,
+            systemPrompt: systemPrompt,
+            maxToken: maxToken,
             temperature: temperature,
-            stop_sequences: validStopSequences
+            stopSequences: validStopSequences,
+            reasoningEffort: reasoningEffort
         )
 
         var request = URLRequest(url: url)
@@ -141,7 +189,10 @@ extension AnthropicService {
         request.setExtraHeaders(extraHeaders)
 
         let encoder = JSONEncoder()
-        request.httpBody = try encoder.encode(requestBody)
+        request.httpBody = CompletionJSON.mergeCustomBody(
+            try encoder.encode(requestBody),
+            jsonBody: customJSONBody
+        )
 
         let (result, response) = try await URLSession.shared.bytes(for: request)
         return try await CompletionHTTP.makeStream(
diff --git a/Tool/Sources/CustomSuggestionService/API/AzureOpenAIService.swift b/Tool/Sources/CustomSuggestionService/API/AzureOpenAIService.swift
index 18c67780..7fd36af1 100644
--- a/Tool/Sources/CustomSuggestionService/API/AzureOpenAIService.swift
+++ b/Tool/Sources/CustomSuggestionService/API/AzureOpenAIService.swift
@@ -11,6 +11,7 @@ public actor AzureOpenAIService {
     let temperature: Double
     let apiKey: String
     let stopWords: [String]
+    let extraHeaders: [(name: String, value: String)]
 
     init(
         url: String? = nil,
@@ -20,7 +21,8 @@ public actor AzureOpenAIService {
         maxToken: Int,
         temperature: Double = 0.2,
         stopWords: [String] = [],
-        apiKey: String
+        apiKey: String,
+        extraHeaders: [(name: String, value: String)] = []
     ) {
         self.url = url.flatMap(URL.init(string:)) ?? {
             switch endpoint {
@@ -38,6 +40,7 @@ public actor AzureOpenAIService {
         self.temperature = temperature
         self.stopWords = stopWords
         self.apiKey = apiKey
+        self.extraHeaders = extraHeaders
     }
 }
 
@@ -106,6 +109,7 @@ extension AzureOpenAIService {
         request.httpBody = try encoder.encode(requestBody)
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
         request.setValue(apiKey, forHTTPHeaderField: "api-key")
+        request.setExtraHeaders(extraHeaders)
         let (result, response) = try await URLSession.shared.bytes(for: request)
         return try await CompletionHTTP.makeStream(
             result: result,
@@ -143,6 +147,7 @@ extension AzureOpenAIService {
         request.httpBody = try encoder.encode(requestBody)
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
         request.setValue(apiKey, forHTTPHeaderField: "api-key")
+        request.setExtraHeaders(extraHeaders)
         let (result, response) = try await URLSession.shared.bytes(for: request)
         return try await CompletionHTTP.makeStream(
             result: result,
diff --git a/Tool/Sources/CustomSuggestionService/API/GoogleGeminiService.swift b/Tool/Sources/CustomSuggestionService/API/GoogleGeminiService.swift
index 28e33617..64066864 100644
--- a/Tool/Sources/CustomSuggestionService/API/GoogleGeminiService.swift
+++ b/Tool/Sources/CustomSuggestionService/API/GoogleGeminiService.swift
@@ -9,6 +9,8 @@ public struct GoogleGeminiService {
     let temperature: Double
     let stopWords: [String]
     let apiKey: String
+    let baseURL: String
+    let apiVersion: String
 
     init(
         modelName: String,
@@ -16,7 +18,9 @@ public struct GoogleGeminiService {
         maxToken: Int,
         temperature: Double = 0.2,
         stopWords: [String] = [],
-        apiKey: String
+        apiKey: String,
+        baseURL: String = "",
+        apiVersion: String = ""
     ) {
         self.modelName = modelName
         self.maxToken = maxToken
@@ -24,6 +28,8 @@ public struct GoogleGeminiService {
         self.temperature = temperature
         self.stopWords = stopWords
         self.apiKey = apiKey
+        self.baseURL = baseURL
+        self.apiVersion = apiVersion
     }
 }
 
@@ -101,7 +107,11 @@ extension GoogleGeminiService {
                 temperature: Float(temperature),
                 maxOutputTokens: maxToken,
                 stopSequences: stopWords
-            ))
+            )),
+            baseURL: baseURL,
+            requestOptions: apiVersion.isEmpty
+                ? .init()
+                : .init(apiVersion: apiVersion)
         )
 
         do {
diff --git a/Tool/Sources/CustomSuggestionService/API/OllamaService.swift b/Tool/Sources/CustomSuggestionService/API/OllamaService.swift
index d594ba0d..864a7953 100644
--- a/Tool/Sources/CustomSuggestionService/API/OllamaService.swift
+++ b/Tool/Sources/CustomSuggestionService/API/OllamaService.swift
@@ -13,6 +13,7 @@ public actor OllamaService {
     let keepAlive: String
     let format: ResponseFormat
     let authenticationMode: AuthenticationMode?
+    let extraHeaders: [(name: String, value: String)]
     
     enum AuthenticationMode {
         case bearerToken(String)
@@ -40,7 +41,8 @@ public actor OllamaService {
         stopWords: [String] = [],
         keepAlive: String = "",
         format: ResponseFormat = .none,
-        authenticationMode: AuthenticationMode? = nil
+        authenticationMode: AuthenticationMode? = nil,
+        extraHeaders: [(name: String, value: String)] = []
     ) {
         self.url = url.flatMap(URL.init(string:)) ?? {
             switch endpoint {
@@ -60,6 +62,7 @@ public actor OllamaService {
         self.format = format
         self.contextWindow = contextWindow
         self.authenticationMode = authenticationMode
+        self.extraHeaders = extraHeaders
     }
 }
 
@@ -208,6 +211,7 @@ extension OllamaService {
         let encoder = JSONEncoder()
         request.httpBody = try encoder.encode(requestBody)
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+        request.setExtraHeaders(extraHeaders)
         let (result, response) = try await URLSession.shared.bytes(for: request)
         return try await CompletionHTTP.makeStream(
             result: result,
@@ -293,6 +297,7 @@ extension OllamaService {
         case let .header(name, value):
             request.setValue(value, forHTTPHeaderField: name)
         }
+        request.setExtraHeaders(extraHeaders)
         
         let (result, response) = try await URLSession.shared.bytes(for: request)
         return try await CompletionHTTP.makeStream(
diff --git a/Tool/Sources/CustomSuggestionService/API/OpenAIResponsesService.swift b/Tool/Sources/CustomSuggestionService/API/OpenAIResponsesService.swift
index 0c34a7da..63121054 100644
--- a/Tool/Sources/CustomSuggestionService/API/OpenAIResponsesService.swift
+++ b/Tool/Sources/CustomSuggestionService/API/OpenAIResponsesService.swift
@@ -1,6 +1,8 @@
 import AIModel
 import CopilotForXcodeKit
 import Foundation
+import Logger
+import Preferences
 
 /// `POST /v1/responses`
 ///
@@ -13,7 +15,13 @@ public actor OpenAIResponsesService {
     let apiKey: String
     let stopWords: [String]
     let reasoningEffort: ReasoningEffort?
+    let reasoningTokenBudget: Int
     let extraHeaders: [(name: String, value: String)]
+    let organizationID: String
+    let projectID: String
+    let customJSONBody: String
+    let requiresBeginWithUserMessage: Bool
+    let enforceMessageOrder: Bool
 
     init(
         url: String? = nil,
@@ -23,7 +31,13 @@ public actor OpenAIResponsesService {
         stopWords: [String] = [],
         apiKey: String,
         reasoningEffort: ReasoningEffort? = nil,
-        extraHeaders: [(name: String, value: String)] = []
+        reasoningTokenBudget: Int = 1500,
+        extraHeaders: [(name: String, value: String)] = [],
+        organizationID: String = "",
+        projectID: String = "",
+        customJSONBody: String = "",
+        requiresBeginWithUserMessage: Bool = false,
+        enforceMessageOrder: Bool = false
     ) {
         self.url = url.flatMap(URL.init(string:))
             ?? URL(string: "https://api.openai.com/v1/responses")!
@@ -33,7 +47,13 @@ public actor OpenAIResponsesService {
         self.apiKey = apiKey
         self.stopWords = stopWords
         self.reasoningEffort = reasoningEffort
+        self.reasoningTokenBudget = reasoningTokenBudget
         self.extraHeaders = extraHeaders
+        self.organizationID = organizationID
+        self.projectID = projectID
+        self.customJSONBody = customJSONBody
+        self.requiresBeginWithUserMessage = requiresBeginWithUserMessage
+        self.enforceMessageOrder = enforceMessageOrder
     }
 }
 
@@ -78,44 +98,97 @@ extension OpenAIResponsesService {
         var instructions: String?
         var stream: Bool
         var max_output_tokens: Int
+        var store: Bool
+        var temperature: Double?
         var reasoning: Reasoning?
     }
 
+    nonisolated static func makeRequestBody(
+        modelName: String,
+        input: [InputItem],
+        instructions: String?,
+        maxToken: Int,
+        reasoningEffort: ReasoningEffort?,
+        reasoningTokenBudget: Int
+    ) -> RequestBody {
+        RequestBody(
+            model: modelName,
+            input: input,
+            instructions: instructions,
+            stream: true,
+            max_output_tokens: ReasoningTokenLimit.maxOutputTokens(
+                maxToken: maxToken,
+                effort: reasoningEffort,
+                budget: reasoningTokenBudget
+            ),
+            store: false,
+            temperature: reasoningEffort == nil ? 0.2 : nil,
+            reasoning: reasoningEffort.map { .init(effort: $0.rawValue) }
+        )
+    }
+
     func createInput(from request: PromptStrategy) -> (instructions: String, input: [InputItem]) {
         let strategy = DefaultTruncateStrategy(maxTokenLimit: max(
             contextWindow / 3 * 2,
             contextWindow - maxToken - 20
         ))
         let prompts = strategy.createTruncatedPrompt(promptStrategy: request)
-        let input = prompts.map { prompt -> InputItem in
+        var ordered: [OrderedChatMessage] = [
+            .init(role: .system, content: request.systemPrompt),
+        ]
+        for prompt in prompts {
             switch prompt.role {
             case .user:
-                return .init(role: "user", content: [.init(type: "input_text", text: prompt.content)])
+                ordered.append(.init(role: .user, content: prompt.content))
+            case .assistant:
+                ordered.append(.init(role: .assistant, content: prompt.content))
+            }
+        }
+        ordered = OrderedChatMessages.apply(
+            ordered,
+            requiresBeginWithUserMessage: requiresBeginWithUserMessage,
+            enforceMessageOrder: enforceMessageOrder
+        )
+        let instructions = ordered.filter { $0.role == .system }.map(\.content)
+            .joined(separator: "\n\n")
+        let input = ordered.compactMap { message -> InputItem? in
+            switch message.role {
+            case .system:
+                return nil
+            case .user:
+                return .init(
+                    role: "user",
+                    content: [.init(type: "input_text", text: message.content)]
+                )
             case .assistant:
                 return .init(
                     role: "assistant",
-                    content: [.init(type: "output_text", text: prompt.content)]
+                    content: [.init(type: "output_text", text: message.content)]
                 )
             }
         }
-        return (request.systemPrompt, input)
+        return (instructions, input)
     }
 
     func send(instructions: String, input: [InputItem]) async throws -> ResponseStream {
-        let requestBody = RequestBody(
-            model: modelName,
+        let requestBody = Self.makeRequestBody(
+            modelName: modelName,
             input: input,
             instructions: instructions.isEmpty ? nil : instructions,
-            stream: true,
-            max_output_tokens: maxToken,
-            reasoning: reasoningEffort.map { .init(effort: $0.rawValue) }
+            maxToken: maxToken,
+            reasoningEffort: reasoningEffort,
+            reasoningTokenBudget: reasoningTokenBudget
         )
 
         var request = URLRequest(url: url)
         request.httpMethod = "POST"
-        request.httpBody = try JSONEncoder().encode(requestBody)
+        request.httpBody = CompletionJSON.mergeCustomBody(
+            try JSONEncoder().encode(requestBody),
+            jsonBody: customJSONBody
+        )
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
         request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+        request.setOpenAIIdentityHeaders(organizationID: organizationID, projectID: projectID)
         request.setExtraHeaders(extraHeaders)
         let (result, response) = try await URLSession.shared.bytes(for: request)
         return try await CompletionHTTP.makeStream(
@@ -168,11 +241,17 @@ extension OpenAIResponsesService {
         var content: [OutputContent]?
     }
 
+    struct IncompleteDetails: Decodable {
+        var reason: String?
+    }
+
     struct ResponseObject: Decodable {
+        var id: String?
         var object: String?
         var status: String?
         var output: [OutputItem]?
         var error: APIError?
+        var incomplete_details: IncompleteDetails?
 
         var outputText: String {
             (output ?? [])
@@ -219,14 +298,28 @@ extension OpenAIResponsesService {
         switch event.type {
         case "response.output_text.delta":
             return .init(chunk: .init(text: event.delta), done: false)
-        case "response.completed", "response.incomplete":
+        case "response.completed":
+            return .init(chunk: nil, done: true)
+        case "response.incomplete":
+            if event.response?.incomplete_details?.reason == "max_output_tokens" {
+                Logger.service.error(
+                    "Completion truncated: incomplete_details.reason=max_output_tokens request_id=\(event.response?.id ?? "unknown")"
+                )
+            }
             return .init(chunk: nil, done: true)
         case "response.failed":
             throw Error.apiError(event.response?.error?.message ?? "The response failed.")
         case "error":
             throw Error.apiError(event.error?.message ?? event.message ?? "Unknown error.")
         case .none where event.object == "response":
-            let body = ResponseObject(object: event.object, status: nil, output: event.output, error: nil)
+            let body = ResponseObject(
+                id: nil,
+                object: event.object,
+                status: nil,
+                output: event.output,
+                error: nil,
+                incomplete_details: nil
+            )
             return .init(chunk: .init(text: body.outputText), done: true)
         default:
             return .init(chunk: nil, done: false)
diff --git a/Tool/Sources/CustomSuggestionService/API/OpenAIService.swift b/Tool/Sources/CustomSuggestionService/API/OpenAIService.swift
index c6e60208..57b0490b 100644
--- a/Tool/Sources/CustomSuggestionService/API/OpenAIService.swift
+++ b/Tool/Sources/CustomSuggestionService/API/OpenAIService.swift
@@ -1,6 +1,8 @@
 import CopilotForXcodeKit
 import Foundation
 import AIModel
+import Logger
+import Preferences
 
 public actor OpenAIService {
     let url: URL
@@ -14,7 +16,13 @@ public actor OpenAIService {
     /// When set, the request takes the reasoning model shape: `reasoning_effort` is sent,
     /// `max_completion_tokens` replaces `max_tokens`, `temperature` and `stop` are omitted.
     let reasoningEffort: ReasoningEffort?
+    let reasoningTokenBudget: Int
     let extraHeaders: [(name: String, value: String)]
+    let organizationID: String
+    let projectID: String
+    let customJSONBody: String
+    let requiresBeginWithUserMessage: Bool
+    let enforceMessageOrder: Bool
 
     public enum Endpoint {
         case completion
@@ -31,10 +39,22 @@ public actor OpenAIService {
         stopWords: [String] = [],
         apiKey: String,
         reasoningEffort: ReasoningEffort? = nil,
-        extraHeaders: [(name: String, value: String)] = []
+        reasoningTokenBudget: Int = 1500,
+        extraHeaders: [(name: String, value: String)] = [],
+        organizationID: String = "",
+        projectID: String = "",
+        customJSONBody: String = "",
+        requiresBeginWithUserMessage: Bool = false,
+        enforceMessageOrder: Bool = false
     ) {
         self.reasoningEffort = reasoningEffort
+        self.reasoningTokenBudget = reasoningTokenBudget
         self.extraHeaders = extraHeaders
+        self.organizationID = organizationID
+        self.projectID = projectID
+        self.customJSONBody = customJSONBody
+        self.requiresBeginWithUserMessage = requiresBeginWithUserMessage
+        self.enforceMessageOrder = enforceMessageOrder
         self.url = url.flatMap(URL.init(string:)) ?? {
             switch endpoint {
             case .chatCompletion:
@@ -128,39 +148,83 @@ extension OpenAIService {
             contextWindow - maxToken - 20
         ))
         let prompts = strategy.createTruncatedPrompt(promptStrategy: request)
-        return [
+        var ordered: [OrderedChatMessage] = [
             .init(role: .system, content: request.systemPrompt),
-        ] + prompts.map { prompt in
+        ]
+        for prompt in prompts {
             switch prompt.role {
             case .user:
-                return .init(role: .user, content: prompt.content)
+                ordered.append(.init(role: .user, content: prompt.content))
             case .assistant:
-                return .init(role: .assistant, content: prompt.content)
+                ordered.append(.init(role: .assistant, content: prompt.content))
             }
         }
+        ordered = OrderedChatMessages.apply(
+            ordered,
+            requiresBeginWithUserMessage: requiresBeginWithUserMessage,
+            enforceMessageOrder: enforceMessageOrder
+        )
+        return ordered.map { message in
+            let role: Message.Role = switch message.role {
+            case .system: .system
+            case .user: .user
+            case .assistant: .assistant
+            }
+            return .init(role: role, content: message.content)
+        }
     }
 
-    func sendMessages(
-        _ messages: [Message]
-    ) async throws -> ResponseStream {
+    nonisolated static func makeChatCompletionRequestBody(
+        modelName: String,
+        messages: [Message],
+        temperature: Double,
+        stopWords: [String],
+        maxToken: Int,
+        reasoningEffort: ReasoningEffort?,
+        reasoningTokenBudget: Int
+    ) -> ChatCompletionRequestBody {
         let isReasoningRequest = reasoningEffort != nil
-        let requestBody = ChatCompletionRequestBody(
+        return ChatCompletionRequestBody(
             model: modelName,
             messages: messages,
             temperature: isReasoningRequest ? nil : temperature,
             stream: true,
             stop: isReasoningRequest ? nil : stopWords,
             max_tokens: isReasoningRequest ? nil : maxToken,
-            max_completion_tokens: isReasoningRequest ? maxToken : nil,
+            max_completion_tokens: isReasoningRequest
+                ? ReasoningTokenLimit.maxOutputTokens(
+                    maxToken: maxToken,
+                    effort: reasoningEffort,
+                    budget: reasoningTokenBudget
+                )
+                : nil,
             reasoning_effort: reasoningEffort?.rawValue
         )
+    }
+
+    func sendMessages(
+        _ messages: [Message]
+    ) async throws -> ResponseStream {
+        let requestBody = Self.makeChatCompletionRequestBody(
+            modelName: modelName,
+            messages: messages,
+            temperature: temperature,
+            stopWords: stopWords,
+            maxToken: maxToken,
+            reasoningEffort: reasoningEffort,
+            reasoningTokenBudget: reasoningTokenBudget
+        )
 
         var request = URLRequest(url: url)
         request.httpMethod = "POST"
         let encoder = JSONEncoder()
-        request.httpBody = try encoder.encode(requestBody)
+        request.httpBody = CompletionJSON.mergeCustomBody(
+            try encoder.encode(requestBody),
+            jsonBody: customJSONBody
+        )
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
         request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+        request.setOpenAIIdentityHeaders(organizationID: organizationID, projectID: projectID)
         request.setExtraHeaders(extraHeaders)
         let (result, response) = try await URLSession.shared.bytes(for: request)
         return try await CompletionHTTP.makeStream(
@@ -185,7 +249,9 @@ extension OpenAIService {
                 ChatCompletionResponseBody.self,
                 from: Data(text.utf8)
             )
-            return chatChunk(from: body)
+            let chunk = chatChunk(from: body)
+            logIfTruncatedByLength(chunk)
+            return chunk
         } catch let error as Error {
             throw error
         } catch {
@@ -193,6 +259,14 @@ extension OpenAIService {
         }
     }
 
+    static func logIfTruncatedByLength(_ chunk: ChatCompletionsStreamDataChunk) {
+        if chunk.choices?.first?.finish_reason == "length" {
+            Logger.service.error(
+                "Completion truncated: finish_reason=length request_id=\(chunk.id ?? "unknown")"
+            )
+        }
+    }
+
     static func chatChunk(from body: ChatCompletionResponseBody) -> ChatCompletionsStreamDataChunk {
         let choice = body.choices.first
         return ChatCompletionsStreamDataChunk(
@@ -239,6 +313,7 @@ extension OpenAIService {
             ChatCompletionsStreamDataChunk.self,
             from: Data(payload.utf8)
         )
+        logIfTruncatedByLength(chunk)
         // A non-streaming body carries the whole message instead of a delta.
         if let choice = chunk.choices?.first, choice.delta == nil, let message = choice.message {
             return .init(
@@ -372,9 +447,13 @@ extension OpenAIService {
         var request = URLRequest(url: url)
         request.httpMethod = "POST"
         let encoder = JSONEncoder()
-        request.httpBody = try encoder.encode(requestBody)
+        request.httpBody = CompletionJSON.mergeCustomBody(
+            try encoder.encode(requestBody),
+            jsonBody: customJSONBody
+        )
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
         request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+        request.setOpenAIIdentityHeaders(organizationID: organizationID, projectID: projectID)
         request.setExtraHeaders(extraHeaders)
         let (result, response) = try await URLSession.shared.bytes(for: request)
         return try await CompletionHTTP.makeStream(
diff --git a/Tool/Sources/CustomSuggestionService/ChatAPISupport.swift b/Tool/Sources/CustomSuggestionService/ChatAPISupport.swift
index 570ffe40..a192cbbd 100644
--- a/Tool/Sources/CustomSuggestionService/ChatAPISupport.swift
+++ b/Tool/Sources/CustomSuggestionService/ChatAPISupport.swift
@@ -1,60 +1,42 @@
 import AIModel
 import Foundation
+import JoinJSON
+import Logger
+import OpenAIService
 import Preferences
 
-/// Which OpenAI endpoint to call for chat models.
-public enum OpenAIChatAPI: String, CaseIterable {
-    /// `POST /v1/chat/completions`
-    case chatCompletions
-    /// `POST /v1/responses`
-    case responses
-}
-
-/// `reasoning_effort` (Chat Completions) / `reasoning.effort` (Responses).
-///
-/// The raw value is sent verbatim. When the preference is empty the field is omitted and the
-/// request keeps the legacy shape (`temperature`, `stop`, `max_tokens`).
-public enum ReasoningEffort: String, CaseIterable {
-    case none
-    case minimal
-    case low
-    case medium
-    case high
-}
-
 /// Per-request options for chat models, resolved from preferences and the model's custom headers.
 struct ChatAPIOptions {
     var api: OpenAIChatAPI
     var reasoningEffort: ReasoningEffort?
+    var reasoningTokenBudget: Int
     var extraHeaders: [(name: String, value: String)]
 
-    static func current(for model: ChatModel, apiKey: String) -> ChatAPIOptions {
-        let api = OpenAIChatAPI(
-            rawValue: UserDefaults.shared.value(for: \.customSuggestionOpenAIAPI)
-        ) ?? .chatCompletions
-        let effort = ReasoningEffort(
-            rawValue: UserDefaults.shared.value(for: \.customSuggestionReasoningEffort)
-        )
-        let headers = model.info.customHeaderInfo.headers
-            .filter { !$0.key.isEmpty }
-            .map { field in
-                (
-                    name: field.key,
-                    value: substitutePlaceholders(
-                        in: field.value,
-                        modelName: model.info.modelName,
-                        apiKey: apiKey
-                    )
+    static func current(
+        for model: ChatModel,
+        apiKey: String,
+        defaults: UserDefaultsType = UserDefaults.shared
+    ) async -> ChatAPIOptions {
+        let stored = ChatModelAPIOptions.stored(for: model.id, defaults: defaults)
+        let parser = HeaderValueParser()
+        var headers: [(name: String, value: String)] = []
+        for field in model.info.customHeaderInfo.headers where !field.key.isEmpty {
+            let value = await parser.parse(
+                field.value,
+                context: .init(
+                    modelName: model.info.modelName,
+                    apiKey: apiKey,
+                    gitHubCopilotToken: { nil }
                 )
-            }
-        return .init(api: api, reasoningEffort: effort, extraHeaders: headers)
-    }
-
-    /// Supports the `{{api_key}}` and `{{model_name}}` placeholders of the host's custom headers.
-    static func substitutePlaceholders(in value: String, modelName: String, apiKey: String) -> String {
-        value
-            .replacingOccurrences(of: "{{api_key}}", with: apiKey)
-            .replacingOccurrences(of: "{{model_name}}", with: modelName)
+            )
+            headers.append((name: field.key, value: value))
+        }
+        return .init(
+            api: stored.api,
+            reasoningEffort: stored.reasoningEffort,
+            reasoningTokenBudget: stored.reasoningTokenBudget,
+            extraHeaders: CompletionHeader.validated(headers)
+        )
     }
 }
 
@@ -64,6 +46,136 @@ extension URLRequest {
             setValue(header.value, forHTTPHeaderField: header.name)
         }
     }
+
+    mutating func setOpenAIIdentityHeaders(organizationID: String, projectID: String) {
+        if !organizationID.isEmpty {
+            setValue(organizationID, forHTTPHeaderField: "OpenAI-Organization")
+        }
+        if !projectID.isEmpty {
+            setValue(projectID, forHTTPHeaderField: "OpenAI-Project")
+        }
+    }
+}
+
+enum CompletionHeader {
+    static func validated(
+        _ headers: [(name: String, value: String)]
+    ) -> [(name: String, value: String)] {
+        headers.filter { header in
+            if !isValidName(header.name) {
+                Logger.service.error("Dropping invalid completion header name: \(header.name)")
+                return false
+            }
+            if header.value.contains(where: \.isNewline) {
+                Logger.service.error(
+                    "Dropping completion header \(header.name) because the value contains a newline"
+                )
+                return false
+            }
+            return true
+        }
+    }
+
+    /// Reject whitespace and control characters in header names.
+    static func isValidName(_ name: String) -> Bool {
+        !name.isEmpty && name.unicodeScalars.allSatisfy { scalar in
+            scalar.value > 32 && scalar.value < 127
+        }
+    }
+}
+
+enum CompletionJSON {
+    static func mergeCustomBody(_ data: Data, jsonBody: String) -> Data {
+        let jsonBody = jsonBody.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !jsonBody.isEmpty else { return data }
+        return JoinJSON().join(data, with: jsonBody)
+    }
+}
+
+enum ReasoningTokenLimit {
+    static func maxOutputTokens(maxToken: Int, effort: ReasoningEffort?, budget: Int) -> Int {
+        if let effort, effort.addsReasoningTokenBudget {
+            return maxToken + budget
+        }
+        return maxToken
+    }
+}
+
+struct OrderedChatMessage {
+    enum Role {
+        case system
+        case user
+        case assistant
+    }
+
+    var role: Role
+    var content: String
+}
+
+enum OrderedChatMessages {
+    static func apply(
+        _ messages: [OrderedChatMessage],
+        requiresBeginWithUserMessage: Bool,
+        enforceMessageOrder: Bool
+    ) -> [OrderedChatMessage] {
+        guard requiresBeginWithUserMessage || enforceMessageOrder else { return messages }
+        var result = messages
+
+        if requiresBeginWithUserMessage {
+            if let firstUser = result.firstIndex(where: { $0.role == .user }) {
+                let leading = result[.. String? {
+        var earliest: String.Index?
+        for word in stopWords where !word.isEmpty {
+            if let range = text.range(of: word),
+               earliest == nil || range.lowerBound < earliest!
+            {
+                earliest = range.lowerBound
+            }
+        }
+        guard let earliest else { return nil }
+        return String(text[..(
+    from stream: S,
+    stopWords: [String],
+    limiter: StreamLineLimiter
+) async throws -> String where S.Element == String {
+    for try await response in stream {
+        let push = limiter.push(response)
+        if let truncated = CompletionStopWords.truncatedPrefix(
+            of: limiter.result,
+            stopWords: stopWords
+        ) {
+            return truncated
+        }
+        if case let .finish(result) = push {
+            return result
+        }
+    }
+    return limiter.result
+}
+
 public struct CodeCompletionService {
     public init() {}
 
-    public enum Error: Swift.Error {
+    public enum Error: Swift.Error, LocalizedError {
         case unknownFormat
+        case gitHubCopilotNotSupported
+
+        public var errorDescription: String? {
+            switch self {
+            case .unknownFormat:
+                return "Unknown model format."
+            case .gitHubCopilotNotSupported:
+                return "GitHub Copilot chat models can't be used for completion"
+            }
+        }
     }
 
     public func getCompletions(
@@ -93,7 +122,7 @@ public struct CodeCompletionService {
     ) async throws -> [String] {
         let apiKey = apiKey(from: model)
 
-        let options = ChatAPIOptions.current(for: model, apiKey: apiKey)
+        let options = await ChatAPIOptions.current(for: model, apiKey: apiKey)
 
         switch model.format {
         case .openAI, .openAICompatible:
@@ -107,7 +136,14 @@ public struct CodeCompletionService {
                     stopWords: prompt.stopWords,
                     apiKey: apiKey,
                     reasoningEffort: options.reasoningEffort,
-                    extraHeaders: options.extraHeaders
+                    reasoningTokenBudget: options.reasoningTokenBudget,
+                    extraHeaders: options.extraHeaders,
+                    organizationID: model.info.openAIInfo.organizationID,
+                    projectID: model.info.openAIInfo.projectID,
+                    customJSONBody: model.info.customBodyInfo.jsonBody,
+                    requiresBeginWithUserMessage: model.info.openAICompatibleInfo
+                        .requiresBeginWithUserMessage,
+                    enforceMessageOrder: model.info.openAICompatibleInfo.enforceMessageOrder
                 )
                 let result = try await service.getCompletions(
                     prompt,
@@ -126,7 +162,14 @@ public struct CodeCompletionService {
                     stopWords: prompt.stopWords,
                     apiKey: apiKey,
                     reasoningEffort: options.reasoningEffort,
-                    extraHeaders: options.extraHeaders
+                    reasoningTokenBudget: options.reasoningTokenBudget,
+                    extraHeaders: options.extraHeaders,
+                    organizationID: model.info.openAIInfo.organizationID,
+                    projectID: model.info.openAIInfo.projectID,
+                    customJSONBody: model.info.customBodyInfo.jsonBody,
+                    requiresBeginWithUserMessage: model.info.openAICompatibleInfo
+                        .requiresBeginWithUserMessage,
+                    enforceMessageOrder: model.info.openAICompatibleInfo.enforceMessageOrder
                 )
                 let result = try await service.getCompletions(
                     prompt,
@@ -144,7 +187,8 @@ public struct CodeCompletionService {
                 contextWindow: model.info.maxTokens,
                 maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken),
                 stopWords: prompt.stopWords,
-                apiKey: apiKey
+                apiKey: apiKey,
+                extraHeaders: options.extraHeaders
             )
             let result = try await service.getCompletions(
                 prompt,
@@ -158,7 +202,9 @@ public struct CodeCompletionService {
                 modelName: model.info.modelName,
                 contextWindow: model.info.maxTokens,
                 maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken),
-                apiKey: apiKey
+                apiKey: apiKey,
+                baseURL: model.endpoint,
+                apiVersion: model.info.googleGenerativeAIInfo.apiVersion
             )
             let result = try await service.getCompletions(
                 prompt,
@@ -176,7 +222,8 @@ public struct CodeCompletionService {
                 maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken),
                 stopWords: prompt.stopWords,
                 keepAlive: model.info.ollamaInfo.keepAlive,
-                format: .none
+                format: .none,
+                extraHeaders: options.extraHeaders
             )
             let result = try await service.getCompletions(
                 prompt,
@@ -193,7 +240,9 @@ public struct CodeCompletionService {
                 maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken),
                 stopWords: prompt.stopWords,
                 apiKey: apiKey,
-                extraHeaders: options.extraHeaders
+                extraHeaders: options.extraHeaders,
+                reasoningEffort: options.reasoningEffort,
+                customJSONBody: model.info.customBodyInfo.jsonBody
             )
             let result = try await service.getCompletions(
                 prompt,
@@ -203,7 +252,7 @@ public struct CodeCompletionService {
             try Task.checkCancellation()
             return result
         case .gitHubCopilot:
-            throw Error.unknownFormat
+            throw Error.gitHubCopilotNotSupported
         }
     }
 
@@ -355,8 +404,42 @@ public struct CodeCompletionService {
     static func responsesEndpoint(for model: ChatModel) -> String {
         let baseURL = model.info.baseURL
         if baseURL.isEmpty { return "https://api.openai.com/v1/responses" }
-        if model.format == .openAICompatible, model.info.isFullURL { return baseURL }
-        return "\(baseURL)/v1/responses"
+        if model.format == .openAICompatible, model.info.isFullURL {
+            return rewriteChatCompletionsFullURL(baseURL)
+        }
+        let trimmed = baseURL.hasSuffix("/") ? String(baseURL.dropLast()) : baseURL
+        return "\(trimmed)/v1/responses"
+    }
+
+    /// Full URLs may have a trailing slash or query; match `/chat/completions` on the path only.
+    static func rewriteChatCompletionsFullURL(_ baseURL: String) -> String {
+        if let url = URL(string: baseURL),
+           var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
+        {
+            var path = components.path
+            while path.count > 1, path.hasSuffix("/") {
+                path.removeLast()
+            }
+            if path.hasSuffix("/chat/completions") {
+                components.path = String(path.dropLast("/chat/completions".count)) + "/responses"
+                if let rewritten = components.string {
+                    return rewritten
+                }
+            }
+            return baseURL
+        }
+
+        let parts = baseURL.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false)
+        var head = String(parts[0])
+        while head.hasSuffix("/") {
+            head.removeLast()
+        }
+        guard head.hasSuffix("/chat/completions") else { return baseURL }
+        head = String(head.dropLast("/chat/completions".count)) + "/responses"
+        if parts.count > 1 {
+            return head + "?" + parts[1]
+        }
+        return head
     }
 
     func apiKey(from model: ChatModel) -> String {
diff --git a/Tool/Sources/Preferences/CustomSuggestionChatModelAPIOptions.swift b/Tool/Sources/Preferences/CustomSuggestionChatModelAPIOptions.swift
new file mode 100644
index 00000000..c4ac7665
--- /dev/null
+++ b/Tool/Sources/Preferences/CustomSuggestionChatModelAPIOptions.swift
@@ -0,0 +1,111 @@
+import Foundation
+
+/// Which OpenAI endpoint to call for chat models.
+public enum OpenAIChatAPI: String, Codable, CaseIterable, Equatable {
+    /// `POST /v1/chat/completions`
+    case chatCompletions
+    /// `POST /v1/responses`
+    case responses
+}
+
+/// `reasoning_effort` (Chat Completions) / `reasoning.effort` (Responses).
+///
+/// The raw value is sent verbatim. When the preference is empty the field is omitted and the
+/// request keeps the legacy shape (`temperature`, `stop`, `max_tokens`).
+public enum ReasoningEffort: String, Codable, CaseIterable, Equatable {
+    case none
+    case minimal
+    case low
+    case medium
+    case high
+    case xhigh
+
+    /// `low/medium/high/xhigh` add `reasoningTokenBudget` to max output tokens.
+    public var addsReasoningTokenBudget: Bool {
+        switch self {
+        case .none, .minimal: return false
+        case .low, .medium, .high, .xhigh: return true
+        }
+    }
+}
+
+/// Per-chat-model API options for the custom suggestion provider.
+public struct ChatModelAPIOptions: Codable, Equatable {
+    public var api: OpenAIChatAPI
+    public var reasoningEffort: ReasoningEffort?
+    public var reasoningTokenBudget: Int
+
+    public init(
+        api: OpenAIChatAPI = .chatCompletions,
+        reasoningEffort: ReasoningEffort? = nil,
+        reasoningTokenBudget: Int = 1500
+    ) {
+        self.api = api
+        self.reasoningEffort = reasoningEffort
+        self.reasoningTokenBudget = reasoningTokenBudget
+    }
+
+    public init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        api = try container.decodeIfPresent(OpenAIChatAPI.self, forKey: .api) ?? .chatCompletions
+        reasoningEffort = try container.decodeIfPresent(
+            ReasoningEffort.self,
+            forKey: .reasoningEffort
+        )
+        reasoningTokenBudget = try container.decodeIfPresent(
+            Int.self,
+            forKey: .reasoningTokenBudget
+        ) ?? 1500
+    }
+
+    /// Reads options for `modelId`, migrating the old global keys once if needed.
+    public static func stored(
+        for modelId: String,
+        defaults: UserDefaultsType = UserDefaults.shared
+    ) -> ChatModelAPIOptions {
+        var dict = defaults.value(for: \.customSuggestionChatModelAPIOptions)
+        if dict.isEmpty, migrateLegacyGlobals(into: &dict, modelId: modelId, defaults: defaults) {
+            defaults.set(dict, for: \.customSuggestionChatModelAPIOptions)
+        }
+        return dict[modelId] ?? ChatModelAPIOptions()
+    }
+
+    public static func update(
+        _ options: ChatModelAPIOptions,
+        for modelId: String,
+        defaults: UserDefaultsType = UserDefaults.shared
+    ) {
+        var dict = defaults.value(for: \.customSuggestionChatModelAPIOptions)
+        if dict.isEmpty {
+            _ = migrateLegacyGlobals(into: &dict, modelId: modelId, defaults: defaults)
+        }
+        dict[modelId] = options
+        defaults.set(dict, for: \.customSuggestionChatModelAPIOptions)
+    }
+
+    /// Old global preference keys, kept for a one-time compatibility read.
+    public static let legacyOpenAIAPIKey = "CustomSuggestionService-OpenAIAPI"
+    public static let legacyReasoningEffortKey = "CustomSuggestionService-ReasoningEffort"
+
+    /// Returns true when values were written into `dict`.
+    @discardableResult
+    public static func migrateLegacyGlobals(
+        into dict: inout [String: ChatModelAPIOptions],
+        modelId: String,
+        defaults: UserDefaultsType
+    ) -> Bool {
+        let oldAPI = defaults.value(forKey: legacyOpenAIAPIKey) as? String
+        let oldEffort = defaults.value(forKey: legacyReasoningEffortKey) as? String
+        let hasNonDefaultAPI = oldAPI != nil && oldAPI != "chatCompletions"
+        let hasNonDefaultEffort = !(oldEffort ?? "").isEmpty
+        guard hasNonDefaultAPI || hasNonDefaultEffort else { return false }
+
+        dict[modelId] = ChatModelAPIOptions(
+            api: OpenAIChatAPI(rawValue: oldAPI ?? "") ?? .chatCompletions,
+            reasoningEffort: oldEffort.flatMap(ReasoningEffort.init(rawValue:))
+        )
+        defaults.set(nil, forKey: legacyOpenAIAPIKey)
+        defaults.set(nil, forKey: legacyReasoningEffortKey)
+        return true
+    }
+}
diff --git a/Tool/Sources/Preferences/CustomSuggestionServiceKeys.swift b/Tool/Sources/Preferences/CustomSuggestionServiceKeys.swift
index 66122cfb..9b4c3233 100644
--- a/Tool/Sources/Preferences/CustomSuggestionServiceKeys.swift
+++ b/Tool/Sources/Preferences/CustomSuggestionServiceKeys.swift
@@ -51,17 +51,15 @@ public extension UserDefaultPreferenceKeys {
         .init(defaultValue: "", key: "CustomSuggestionService-RequestStrategyId")
     }
 
-    /// Raw value of `OpenAIChatAPI`: which endpoint OpenAI (compatible) chat models are called with.
-    var customSuggestionOpenAIAPI: PreferenceKey {
-        .init(defaultValue: "chatCompletions", key: "CustomSuggestionService-OpenAIAPI")
-    }
-
-    /// Raw value of `ReasoningEffort`. Empty means the field is not sent.
-    var customSuggestionReasoningEffort: PreferenceKey {
-        .init(defaultValue: "", key: "CustomSuggestionService-ReasoningEffort")
+    /// Per chat-model API options, keyed by `ChatModel.id`.
+    var customSuggestionChatModelAPIOptions: PreferenceKey> {
+        .init(
+            defaultValue: .init([:]),
+            key: "CustomSuggestionService-ChatModelAPIOptions"
+        )
     }
 
-    /// 0 means unlimited.
+    /// 0 means stop words only.
     var customSuggestionMaxLines: PreferenceKey {
         .init(defaultValue: 0, key: "CustomSuggestionService-MaxNumberOfLinesOfSuggestion")
     }
diff --git a/Tool/Tests/CustomSuggestionServiceTests/ChatAPIOptionsAndSemanticsTests.swift b/Tool/Tests/CustomSuggestionServiceTests/ChatAPIOptionsAndSemanticsTests.swift
new file mode 100644
index 00000000..a913cb67
--- /dev/null
+++ b/Tool/Tests/CustomSuggestionServiceTests/ChatAPIOptionsAndSemanticsTests.swift
@@ -0,0 +1,277 @@
+import AIModel
+import Foundation
+import Preferences
+import XCTest
+
+@testable import CustomSuggestionService
+
+final class ChatAPIOptionsAndSemanticsTests: XCTestCase {
+    func test_responsesEndpoint_four_branches() {
+        XCTAssertEqual(
+            CodeCompletionService.responsesEndpoint(for: chatModel(baseURL: "")),
+            "https://api.openai.com/v1/responses"
+        )
+        XCTAssertEqual(
+            CodeCompletionService.responsesEndpoint(for: chatModel(
+                baseURL: "https://gw.example/v1/chat/completions",
+                isFullURL: true
+            )),
+            "https://gw.example/v1/responses"
+        )
+        XCTAssertEqual(
+            CodeCompletionService.responsesEndpoint(for: chatModel(
+                baseURL: "https://gw.example/custom/responses",
+                isFullURL: true
+            )),
+            "https://gw.example/custom/responses"
+        )
+        XCTAssertEqual(
+            CodeCompletionService.responsesEndpoint(for: chatModel(
+                baseURL: "https://gw.example/",
+                isFullURL: false
+            )),
+            "https://gw.example/v1/responses"
+        )
+        XCTAssertEqual(
+            CodeCompletionService.responsesEndpoint(for: chatModel(
+                baseURL: "https://gw.example/v1/chat/completions/",
+                isFullURL: true
+            )),
+            "https://gw.example/v1/responses"
+        )
+        XCTAssertEqual(
+            CodeCompletionService.responsesEndpoint(for: chatModel(
+                baseURL: "https://gw.example/v1/chat/completions?api-version=1",
+                isFullURL: true
+            )),
+            "https://gw.example/v1/responses?api-version=1"
+        )
+        XCTAssertEqual(
+            CodeCompletionService.responsesEndpoint(for: ChatModel(
+                id: "id",
+                name: "Test",
+                format: .openAI,
+                info: .init(
+                    baseURL: "https://api.openai.com",
+                    isFullURL: true,
+                    modelName: "gpt-5.5"
+                )
+            )),
+            "https://api.openai.com/v1/responses"
+        )
+    }
+
+    func test_chatModelAPIOptions_per_id_read_write_and_legacy_migration() {
+        let (defaults, suite) = isolatedDefaults()
+        defer { defaults.removePersistentDomain(forName: suite) }
+
+        ChatModelAPIOptions.update(
+            .init(api: .responses, reasoningEffort: .high, reasoningTokenBudget: 2000),
+            for: "model-a",
+            defaults: defaults
+        )
+        let storedA = ChatModelAPIOptions.stored(for: "model-a", defaults: defaults)
+        XCTAssertEqual(storedA.api, .responses)
+        XCTAssertEqual(storedA.reasoningEffort, .high)
+        XCTAssertEqual(storedA.reasoningTokenBudget, 2000)
+        XCTAssertEqual(
+            ChatModelAPIOptions.stored(for: "model-b", defaults: defaults),
+            ChatModelAPIOptions()
+        )
+
+        let (legacyDefaults, legacySuite) = isolatedDefaults()
+        defer { legacyDefaults.removePersistentDomain(forName: legacySuite) }
+        legacyDefaults.set("responses", forKey: ChatModelAPIOptions.legacyOpenAIAPIKey)
+        legacyDefaults.set("minimal", forKey: ChatModelAPIOptions.legacyReasoningEffortKey)
+
+        let migrated = ChatModelAPIOptions.stored(for: "model-1", defaults: legacyDefaults)
+        XCTAssertEqual(migrated.api, .responses)
+        XCTAssertEqual(migrated.reasoningEffort, .minimal)
+        XCTAssertNil(legacyDefaults.value(forKey: ChatModelAPIOptions.legacyOpenAIAPIKey))
+        XCTAssertNil(legacyDefaults.value(forKey: ChatModelAPIOptions.legacyReasoningEffortKey))
+        XCTAssertEqual(
+            ChatModelAPIOptions.stored(for: "model-2", defaults: legacyDefaults).api,
+            .chatCompletions
+        )
+    }
+
+    func test_chat_request_body_legacy_and_reasoning_shapes() throws {
+        let messages = [OpenAIService.Message(role: .user, content: "hi")]
+        let legacy = OpenAIService.makeChatCompletionRequestBody(
+            modelName: "gpt-5.5",
+            messages: messages,
+            temperature: 0.2,
+            stopWords: ["\n\n"],
+            maxToken: 200,
+            reasoningEffort: nil,
+            reasoningTokenBudget: 1500
+        )
+        let legacyJSON = try jsonObject(legacy)
+        XCTAssertEqual(legacyJSON["temperature"] as? Double, 0.2)
+        XCTAssertEqual(legacyJSON["stop"] as? [String], ["\n\n"])
+        XCTAssertEqual((legacyJSON["max_tokens"] as? NSNumber)?.intValue, 200)
+        XCTAssertNil(legacyJSON["max_completion_tokens"])
+        XCTAssertNil(legacyJSON["reasoning_effort"])
+
+        let reasoning = OpenAIService.makeChatCompletionRequestBody(
+            modelName: "gpt-5.5",
+            messages: messages,
+            temperature: 0.2,
+            stopWords: ["\n\n"],
+            maxToken: 200,
+            reasoningEffort: .high,
+            reasoningTokenBudget: 1500
+        )
+        let reasoningJSON = try jsonObject(reasoning)
+        XCTAssertNil(reasoningJSON["temperature"])
+        XCTAssertNil(reasoningJSON["stop"])
+        XCTAssertNil(reasoningJSON["max_tokens"])
+        XCTAssertEqual((reasoningJSON["max_completion_tokens"] as? NSNumber)?.intValue, 1700)
+        XCTAssertEqual(reasoningJSON["reasoning_effort"] as? String, "high")
+
+        let minimal = OpenAIService.makeChatCompletionRequestBody(
+            modelName: "gpt-5.5",
+            messages: messages,
+            temperature: 0.2,
+            stopWords: ["\n\n"],
+            maxToken: 200,
+            reasoningEffort: .minimal,
+            reasoningTokenBudget: 1500
+        )
+        let minimalJSON = try jsonObject(minimal)
+        XCTAssertEqual((minimalJSON["max_completion_tokens"] as? NSNumber)?.intValue, 200)
+        XCTAssertEqual(minimalJSON["reasoning_effort"] as? String, "minimal")
+    }
+
+    func test_responses_request_body_store_false_and_temperature_when_effort_nil() throws {
+        let withoutEffort = OpenAIResponsesService.makeRequestBody(
+            modelName: "gpt-5.5",
+            input: [],
+            instructions: "complete",
+            maxToken: 200,
+            reasoningEffort: nil,
+            reasoningTokenBudget: 1500
+        )
+        let json = try jsonObject(withoutEffort)
+        XCTAssertEqual(json["store"] as? Bool, false)
+        XCTAssertEqual(json["temperature"] as? Double, 0.2)
+        XCTAssertEqual((json["max_output_tokens"] as? NSNumber)?.intValue, 200)
+        XCTAssertNil(json["reasoning"])
+
+        let withEffort = OpenAIResponsesService.makeRequestBody(
+            modelName: "gpt-5.5",
+            input: [],
+            instructions: "complete",
+            maxToken: 200,
+            reasoningEffort: .xhigh,
+            reasoningTokenBudget: 1500
+        )
+        let effortJSON = try jsonObject(withEffort)
+        XCTAssertEqual(effortJSON["store"] as? Bool, false)
+        XCTAssertNil(effortJSON["temperature"])
+        XCTAssertEqual((effortJSON["max_output_tokens"] as? NSNumber)?.intValue, 1700)
+        XCTAssertEqual((effortJSON["reasoning"] as? [String: Any])?["effort"] as? String, "xhigh")
+    }
+
+    func test_anthropic_request_body_contains_output_config_effort() throws {
+        let body = AnthropicService.makeMessageRequestBody(
+            modelName: "claude-3-5-sonnet-latest",
+            messages: [.init(role: .user, content: "hi")],
+            systemPrompt: "complete",
+            maxToken: 200,
+            temperature: 0.2,
+            stopSequences: ["\n\n"],
+            reasoningEffort: .minimal
+        )
+        let json = try jsonObject(body)
+        XCTAssertEqual(
+            (json["output_config"] as? [String: Any])?["effort"] as? String,
+            "low"
+        )
+
+        let high = AnthropicService.makeMessageRequestBody(
+            modelName: "claude-3-5-sonnet-latest",
+            messages: [.init(role: .user, content: "hi")],
+            systemPrompt: nil,
+            maxToken: 200,
+            temperature: 0.2,
+            stopSequences: nil,
+            reasoningEffort: .xhigh
+        )
+        XCTAssertEqual(
+            (try jsonObject(high)["output_config"] as? [String: Any])?["effort"] as? String,
+            "high"
+        )
+    }
+
+    func test_client_stop_word_truncation() async throws {
+        let stream = AsyncThrowingStream { continuation in
+            continuation.yield("hello")
+            continuation.yield(" world")
+            continuation.yield("\n\n")
+            continuation.yield("more")
+            continuation.finish()
+        }
+        let limiter = StreamLineLimiter(lineLimit: 0, strategy: NeverStreamStopStrategy())
+        let result = try await collectCompletion(
+            from: stream,
+            stopWords: ["\n\n"],
+            limiter: limiter
+        )
+        XCTAssertEqual(result, "hello world")
+    }
+
+    func test_client_stop_word_truncation_split_across_tokens() async throws {
+        let stream = AsyncThrowingStream { continuation in
+            continuation.yield("hello")
+            continuation.yield("\n")
+            continuation.yield("\nmore")
+            continuation.yield("tail")
+            continuation.finish()
+        }
+        let limiter = StreamLineLimiter(lineLimit: 0, strategy: NeverStreamStopStrategy())
+        let result = try await collectCompletion(
+            from: stream,
+            stopWords: ["\n\n"],
+            limiter: limiter
+        )
+        XCTAssertEqual(result, "hello")
+    }
+
+    func test_customBodyInfo_joinJSON_merge() throws {
+        let original = Data(#"{"model":"a","stream":true}"#.utf8)
+        let merged = CompletionJSON.mergeCustomBody(
+            original,
+            jsonBody: #"{"foo":1,"model":"b"}"#
+        )
+        let json = try XCTUnwrap(JSONSerialization.jsonObject(with: merged) as? [String: Any])
+        XCTAssertEqual((json["foo"] as? NSNumber)?.intValue, 1)
+        XCTAssertEqual(json["model"] as? String, "b")
+        XCTAssertEqual(json["stream"] as? Bool, true)
+        XCTAssertEqual(
+            CompletionJSON.mergeCustomBody(original, jsonBody: "  "),
+            original
+        )
+    }
+}
+
+private func jsonObject(_ value: T) throws -> [String: Any] {
+    let data = try JSONEncoder().encode(value)
+    return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
+}
+
+private func isolatedDefaults() -> (UserDefaults, String) {
+    let name = "wp3.semantics.\(UUID().uuidString)"
+    let defaults = UserDefaults(suiteName: name)!
+    defaults.removePersistentDomain(forName: name)
+    return (defaults, name)
+}
+
+private func chatModel(baseURL: String, isFullURL: Bool = false) -> ChatModel {
+    ChatModel(
+        id: "id",
+        name: "Test",
+        format: .openAICompatible,
+        info: .init(baseURL: baseURL, isFullURL: isFullURL, modelName: "gpt-5.5")
+    )
+}
diff --git a/Tool/Tests/CustomSuggestionServiceTests/ChatAPIStreamParsingTests.swift b/Tool/Tests/CustomSuggestionServiceTests/ChatAPIStreamParsingTests.swift
index e789f5cd..67d0b785 100644
--- a/Tool/Tests/CustomSuggestionServiceTests/ChatAPIStreamParsingTests.swift
+++ b/Tool/Tests/CustomSuggestionServiceTests/ChatAPIStreamParsingTests.swift
@@ -1,5 +1,7 @@
+import AIModel
 import CopilotForXcodeKit
 import Foundation
+import Preferences
 import XCTest
 
 @testable import CustomSuggestionService
@@ -92,15 +94,30 @@ class ChatAPIStreamParsingTests: XCTestCase {
         XCTAssertNil(SSELine.payload(of: "   "))
     }
 
-    func test_header_placeholders() {
-        XCTAssertEqual(
-            ChatAPIOptions.substitutePlaceholders(
-                in: "Bearer {{api_key}} for {{model_name}}",
+    func test_header_placeholders() async {
+        let (defaults, suite) = isolatedDefaults()
+        defer { defaults.removePersistentDomain(forName: suite) }
+
+        let model = ChatModel(
+            id: "header-model",
+            name: "Headers",
+            format: .openAI,
+            info: .init(
                 modelName: "gpt-5.5",
-                apiKey: "sk-1"
-            ),
-            "Bearer sk-1 for gpt-5.5"
+                customHeaderInfo: .init(headers: [
+                    .init(key: "X-Auth", value: "Bearer {{api_key}} for {{model_name}}"),
+                    .init(key: "Bad Name", value: "nope"),
+                    .init(key: "X-NL", value: "a\nb"),
+                ])
+            )
+        )
+        let options = await ChatAPIOptions.current(
+            for: model,
+            apiKey: "sk-1",
+            defaults: defaults
         )
+        XCTAssertEqual(options.extraHeaders.map(\.name), ["X-Auth"])
+        XCTAssertEqual(options.extraHeaders.first?.value, "Bearer sk-1 for gpt-5.5")
     }
 
     func test_chatCompletions_error_line_throws_apiError() {
@@ -234,6 +251,13 @@ class ChatAPIStreamParsingTests: XCTestCase {
     }
 }
 
+private func isolatedDefaults() -> (UserDefaults, String) {
+    let name = "wp3.tests.\(UUID().uuidString)"
+    let defaults = UserDefaults(suiteName: name)!
+    defaults.removePersistentDomain(forName: name)
+    return (defaults, name)
+}
+
 private struct ErrorVisibilityPrompt: PromptStrategy {
     var systemPrompt: String { "complete" }
     var prefix: [String] { ["let x = "] }

From 30ecc2ac3f0240ab6e88da1671bd7684348c3972 Mon Sep 17 00:00:00 2001
From: Skyler Liu <9743878+JayDaniel@users.noreply.github.com>
Date: Mon, 7 Sep 2026 19:17:51 -0700
Subject: [PATCH 10/16] Restart debug services after a rebuild

- Fingerprint a debug build by the mtime and size of
  .debug.dylib and quit on change; release keeps the
  FileChangeChecker MD5
- Skip bridge.quit() from XPCController.quit() in debug, which used to
  trip launchd's ten second throttle
- Return an endpoint only for a live PID, and clear the endpoint and
  PID when the liveness check fails, rather than holding a stale
  handle because a bundle id was reused; ping every 5 seconds in
  debug and every 60 in release
- Reuse an instance from launch() only when its bundleURL and appURL
  normalise to the same path, and go through openApplication otherwise
- Build the plist with PropertyListSerialization and compare parsed
  dictionaries for idempotency; mention login items only when stderr
  reports an input/output error or a disabled agent
- Make setupLaunchAgentIfNeeded cancellable, run the automatic
  registration once per host process, always re-register from the
  Setup button, and clear didRunInThisProcess on Remove
- Log LaunchAgentManager under Bundle.main.bundleIdentifier

Add LaunchAgentManagerTests, covering a Program path containing an
ampersand. The three manual checks, pid change after a rebuild,
kickstart and single instance, were not run, this machine having no
GUI.
---
 CommunicationBridge/ServiceDelegate.swift     | 60 ++++++++++--
 Copilot for Xcode.xcodeproj/project.pbxproj   |  4 +
 Core/Package.swift                            |  4 +
 Core/Sources/HostApp/General.swift            |  4 +-
 .../LaunchAgentManager+DebugBuild.swift       | 98 ++++++++++++-------
 .../LaunchAgentManager.swift                  |  7 ++
 .../DebugLaunchAgentPlistTests.swift          | 48 +++++++++
 ExtensionService/AppDelegate.swift            | 11 ++-
 ExtensionService/ExecutableFingerprint.swift  | 19 ++++
 ExtensionService/XPCController.swift          |  8 ++
 TestPlan.xctestplan                           |  7 ++
 11 files changed, 227 insertions(+), 43 deletions(-)
 create mode 100644 Core/Tests/LaunchAgentManagerTests/DebugLaunchAgentPlistTests.swift
 create mode 100644 ExtensionService/ExecutableFingerprint.swift

diff --git a/CommunicationBridge/ServiceDelegate.swift b/CommunicationBridge/ServiceDelegate.swift
index 085b4813..a42f64b4 100644
--- a/CommunicationBridge/ServiceDelegate.swift
+++ b/CommunicationBridge/ServiceDelegate.swift
@@ -12,7 +12,7 @@ class ServiceDelegate: NSObject, NSXPCListenerDelegate {
             with: CommunicationBridgeXPCServiceProtocol.self
         )
 
-        let exportedObject = XPCService()
+        let exportedObject = XPCService(processIdentifier: newConnection.processIdentifier)
         newConnection.exportedObject = exportedObject
         newConnection.resume()
 
@@ -24,6 +24,11 @@ class ServiceDelegate: NSObject, NSXPCListenerDelegate {
 
 class XPCService: CommunicationBridgeXPCServiceProtocol {
     static let eventHandler = EventHandler()
+    let processIdentifier: pid_t
+
+    init(processIdentifier: pid_t) {
+        self.processIdentifier = processIdentifier
+    }
 
     func launchExtensionServiceIfNeeded(
         withReply reply: @escaping (NSXPCListenerEndpoint?) -> Void
@@ -44,13 +49,18 @@ class XPCService: CommunicationBridgeXPCServiceProtocol {
         withReply reply: @escaping () -> Void
     ) {
         Task {
-            await Self.eventHandler.updateServiceEndpoint(endpoint: endpoint, withReply: reply)
+            await Self.eventHandler.updateServiceEndpoint(
+                endpoint: endpoint,
+                processIdentifier: processIdentifier,
+                withReply: reply
+            )
         }
     }
 }
 
 actor EventHandler {
     var endpoint: NSXPCListenerEndpoint?
+    var endpointPID: pid_t?
     let launcher = ExtensionServiceLauncher()
     var exitTask: Task?
 
@@ -62,11 +72,24 @@ actor EventHandler {
         withReply reply: @escaping (NSXPCListenerEndpoint?) -> Void
     ) async {
         rescheduleExitTask()
-        if await launcher.isApplicationValid {
+        if let endpoint,
+           let pid = endpointPID,
+           let running = NSRunningApplication(processIdentifier: pid),
+           !running.isTerminated
+        {
             Logger.communicationBridge.info("Service app is still valid")
+            await launcher.attach(running)
             reply(endpoint)
+            return
+        }
+        // Stale anonymous listener: do not keep a handle just because some other
+        // process with the same bundle id is still running.
+        endpoint = nil
+        endpointPID = nil
+        if await launcher.isApplicationValid {
+            Logger.communicationBridge.info("Service app is still valid")
+            reply(nil)
         } else {
-            endpoint = nil
             await launcher.launch()
             reply(nil)
         }
@@ -78,9 +101,14 @@ actor EventHandler {
         exit(0)
     }
 
-    func updateServiceEndpoint(endpoint: NSXPCListenerEndpoint, withReply reply: () -> Void) {
+    func updateServiceEndpoint(
+        endpoint: NSXPCListenerEndpoint,
+        processIdentifier: pid_t,
+        withReply reply: () -> Void
+    ) {
         rescheduleExitTask()
         self.endpoint = endpoint
+        endpointPID = processIdentifier
         reply()
     }
 
@@ -124,7 +152,16 @@ actor ExtensionServiceLauncher {
         return false
     }
 
+    func attach(_ application: NSRunningApplication) {
+        self.application = application
+    }
+
     func launch() {
+        if let running = runningApplicationMatchingAppURL() {
+            application = running
+            return
+        }
+
         guard !isLaunching else { return }
         isLaunching = true
 
@@ -154,5 +191,16 @@ actor ExtensionServiceLauncher {
             self.isLaunching = false
         }
     }
-}
 
+    /// Prefer the instance launched from this package; other installs with the same
+    /// bundle id fall through to `openApplication(at: appURL)`.
+    private func runningApplicationMatchingAppURL() -> NSRunningApplication? {
+        let wanted = appURL.standardizedFileURL
+        return NSRunningApplication.runningApplications(withBundleIdentifier: appIdentifier)
+            .first { running in
+                guard !running.isTerminated else { return false }
+                guard let bundleURL = running.bundleURL else { return false }
+                return bundleURL.standardizedFileURL == wanted
+            }
+    }
+}
diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj
index 99fd8790..3ed6baa6 100644
--- a/Copilot for Xcode.xcodeproj/project.pbxproj	
+++ b/Copilot for Xcode.xcodeproj/project.pbxproj	
@@ -35,6 +35,7 @@
 		C8738B6B2BE4D56F00609E7F /* ServiceDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8738B6A2BE4D56F00609E7F /* ServiceDelegate.swift */; };
 		C8738B6F2BE4F7A600609E7F /* XPCShared in Frameworks */ = {isa = PBXBuildFile; productRef = C8738B6E2BE4F7A600609E7F /* XPCShared */; };
 		C8738B712BE4F8B700609E7F /* XPCController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8738B702BE4F8B700609E7F /* XPCController.swift */; };
+		C8F4D1026F0AF0FD00A4C002 /* ExecutableFingerprint.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8F4D1016F0AF0FD00A4C001 /* ExecutableFingerprint.swift */; };
 		C8738B7B2BE5363800609E7F /* SandboxedClientTesterApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8738B7A2BE5363800609E7F /* SandboxedClientTesterApp.swift */; };
 		C8738B7D2BE5363800609E7F /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8738B7C2BE5363800609E7F /* ContentView.swift */; };
 		C8738B7F2BE5363900609E7F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C8738B7E2BE5363900609E7F /* Assets.xcassets */; };
@@ -219,6 +220,7 @@
 		C8738B6A2BE4D56F00609E7F /* ServiceDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceDelegate.swift; sourceTree = ""; };
 		C8738B6D2BE4F3E800609E7F /* bridgeLaunchAgent.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = bridgeLaunchAgent.plist; sourceTree = ""; };
 		C8738B702BE4F8B700609E7F /* XPCController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XPCController.swift; sourceTree = ""; };
+		C8F4D1016F0AF0FD00A4C001 /* ExecutableFingerprint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecutableFingerprint.swift; sourceTree = ""; };
 		C8738B782BE5363800609E7F /* SandboxedClientTester.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SandboxedClientTester.app; sourceTree = BUILT_PRODUCTS_DIR; };
 		C8738B7A2BE5363800609E7F /* SandboxedClientTesterApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxedClientTesterApp.swift; sourceTree = ""; };
 		C8738B7C2BE5363800609E7F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
@@ -408,6 +410,7 @@
 				C861E6102994F6070056CB02 /* AppDelegate.swift */,
 				C89E75C22A46FB32000DD64F /* AppDelegate+Menu.swift */,
 				C8738B702BE4F8B700609E7F /* XPCController.swift */,
+				C8F4D1016F0AF0FD00A4C001 /* ExecutableFingerprint.swift */,
 				C81291D52994FE6900196E12 /* Main.storyboard */,
 				C861E6142994F6080056CB02 /* Assets.xcassets */,
 				C861E6192994F6080056CB02 /* ExtensionService.entitlements */,
@@ -719,6 +722,7 @@
 			files = (
 				C89E75C32A46FB32000DD64F /* AppDelegate+Menu.swift in Sources */,
 				C8738B712BE4F8B700609E7F /* XPCController.swift in Sources */,
+				C8F4D1026F0AF0FD00A4C002 /* ExecutableFingerprint.swift in Sources */,
 				C861E6202994F63A0056CB02 /* ServiceDelegate.swift in Sources */,
 				C861E6112994F6070056CB02 /* AppDelegate.swift in Sources */,
 			);
diff --git a/Core/Package.swift b/Core/Package.swift
index 53c92eeb..4c0b3f0d 100644
--- a/Core/Package.swift
+++ b/Core/Package.swift
@@ -248,6 +248,10 @@ let package = Package(
 
         .target(name: "FileChangeChecker"),
         .target(name: "LaunchAgentManager"),
+        .testTarget(
+            name: "LaunchAgentManagerTests",
+            dependencies: ["LaunchAgentManager"]
+        ),
         .target(
             name: "UpdateChecker",
             dependencies: [
diff --git a/Core/Sources/HostApp/General.swift b/Core/Sources/HostApp/General.swift
index b8818e55..49592392 100644
--- a/Core/Sources/HostApp/General.swift
+++ b/Core/Sources/HostApp/General.swift
@@ -43,6 +43,7 @@ struct General {
     @Dependency(\.toast) var toast
 
     struct ReloadStatusCancellableId: Hashable {}
+    struct SetupLaunchAgentCancellableId: Hashable {}
 
     static var didWarnInstallationPosition: Bool {
         get { UserDefaults.standard.bool(forKey: "didWarnInstallationPosition") }
@@ -96,11 +97,12 @@ struct General {
                     do {
                         try await LaunchAgentManager()
                             .setupLaunchAgentForTheFirstTimeIfNeeded()
+                    } catch is CancellationError {
                     } catch {
                         toast(error.localizedDescription, .error)
                     }
                     await send(.reloadStatus)
-                }
+                }.cancellable(id: SetupLaunchAgentCancellableId(), cancelInFlight: true)
 
             case .setupLaunchAgentClicked:
                 if Self.bundleIsInApplicationsFolder {
diff --git a/Core/Sources/LaunchAgentManager/LaunchAgentManager+DebugBuild.swift b/Core/Sources/LaunchAgentManager/LaunchAgentManager+DebugBuild.swift
index 611e702a..fcb77c9a 100644
--- a/Core/Sources/LaunchAgentManager/LaunchAgentManager+DebugBuild.swift
+++ b/Core/Sources/LaunchAgentManager/LaunchAgentManager+DebugBuild.swift
@@ -1,14 +1,46 @@
-#if DEBUG
 import Foundation
+#if DEBUG
 import os
+#endif
+
+public enum DebugLaunchAgentPlist {
+    public static func xmlData(
+        serviceIdentifier: String,
+        programPath: String,
+        bundleIdentifier: String
+    ) throws -> Data {
+        let plist: [String: Any] = [
+            "Label": serviceIdentifier,
+            "Program": programPath,
+            "MachServices": [serviceIdentifier: true],
+            "AssociatedBundleIdentifiers": [bundleIdentifier, serviceIdentifier],
+        ]
+        return try PropertyListSerialization.data(
+            fromPropertyList: plist,
+            format: .xml,
+            options: 0
+        )
+    }
+
+    public static func propertyListsEqual(_ lhs: Data, _ rhs: Data) -> Bool {
+        func object(_ data: Data) -> NSObject? {
+            (try? PropertyListSerialization.propertyList(from: data, options: [], format: nil)) as? NSObject
+        }
+        guard let left = object(lhs), let right = object(rhs) else { return false }
+        return left == right
+    }
+}
 
+#if DEBUG
 /// Debug builds run straight from DerivedData, so the release flow (`SMAppService` with the
 /// plist bundled for /Applications) can't be used. Upstream expects developers to run every
 /// target from Xcode by hand; this fork instead registers the bridge automatically so a plain
 /// "Run" of the host app brings the whole service chain up.
 extension LaunchAgentManager {
+    static var didRunInThisProcess = false
+
     private static let logger = os.Logger(
-        subsystem: "com.intii.CopilotForXcode",
+        subsystem: Bundle.main.bundleIdentifier ?? "com.intii.CopilotForXcode",
         category: "LaunchAgentManager"
     )
 
@@ -21,9 +53,32 @@ extension LaunchAgentManager {
     /// 2. bootstraps it into the user's launchd domain when it isn't loaded;
     /// 3. restarts the bridge when its binary was rebuilt since the last restart, so the freshly
     ///    built code is the one serving XPC requests.
+    /// Automatic `.appear` path: register at most once per host process.
+    public func setupLaunchAgentForDebugBuildIfNeeded() async throws {
+        if Self.didRunInThisProcess { return }
+        Self.didRunInThisProcess = true
+        do {
+            try await setupLaunchAgentForDebugBuild()
+        } catch {
+            Self.didRunInThisProcess = false
+            throw error
+        }
+    }
+
+    /// Explicit Setup button: always re-register.
     public func setupLaunchAgentForDebugBuild() async throws {
-        let plist = debugPlistContent
-        let existing = try? String(contentsOfFile: launchAgentPath, encoding: .utf8)
+        try await registerDebugLaunchAgentIfNeeded()
+        Self.didRunInThisProcess = true
+    }
+
+    private func registerDebugLaunchAgentIfNeeded() async throws {
+        let plist = try DebugLaunchAgentPlist.xmlData(
+            serviceIdentifier: serviceIdentifier,
+            programPath: executableURL.path,
+            bundleIdentifier: bundleIdentifier
+        )
+        let existing = try? Data(contentsOf: URL(fileURLWithPath: launchAgentPath))
+        let plistChanged = existing.map { !DebugLaunchAgentPlist.propertyListsEqual($0, plist) } ?? true
         let isLoaded = await isDebugLaunchAgentLoaded()
         let bridgeModificationDate = try? FileManager.default
             .attributesOfItem(atPath: executableURL.path)[.modificationDate] as? Date
@@ -31,7 +86,7 @@ extension LaunchAgentManager {
         let lastBridgeModificationDate = defaults
             .object(forKey: Self.bridgeModificationDateKey) as? Date
 
-        if existing != plist {
+        if plistChanged {
             if isLoaded {
                 try await debugLaunchctl("bootout", serviceTarget)
                 await waitUntilDebugLaunchAgentUnloaded()
@@ -57,39 +112,12 @@ private extension LaunchAgentManager {
     var domainTarget: String { "gui/\(getuid())" }
     var serviceTarget: String { "\(domainTarget)/\(serviceIdentifier)" }
 
-    /// Same template as `setupLaunchAgentWithDynamicPlist`, kept here so the release code path
-    /// stays byte-for-byte upstream.
-    var debugPlistContent: String {
-        """
-        
-        
-        
-        
-            Label
-            \(serviceIdentifier)
-            Program
-            \(executableURL.path)
-            MachServices
-            
-                \(serviceIdentifier)
-                
-            
-            AssociatedBundleIdentifiers
-            
-                \(bundleIdentifier)
-                \(serviceIdentifier)
-            
-        
-        
-        """
-    }
-
-    func writeDebugPlist(_ content: String) throws {
+    func writeDebugPlist(_ data: Data) throws {
         try FileManager.default.createDirectory(
             at: launchAgentDirURL,
             withIntermediateDirectories: true
         )
-        try content.write(toFile: launchAgentPath, atomically: true, encoding: .utf8)
+        try data.write(to: URL(fileURLWithPath: launchAgentPath), options: .atomic)
     }
 
     func isDebugLaunchAgentLoaded() async -> Bool {
@@ -145,7 +173,7 @@ private struct DebugLaunchAgentError: Error, LocalizedError {
     var errorDescription: String? {
         var message = "\(command) failed (\(status))"
         if !output.isEmpty { message += ": \(output)" }
-        if output.contains("Input/output error") || status == 5 {
+        if output.contains("Input/output error") || output.contains("disabled") {
             message += ". If the login item was disabled, enable it in System Settings › General › Login Items & Extensions."
         }
         return message
diff --git a/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift
index a147ceb5..7e713a8e 100644
--- a/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift
+++ b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift
@@ -23,6 +23,9 @@ public struct LaunchAgentManager {
     }
 
     public func setupLaunchAgentForTheFirstTimeIfNeeded() async throws {
+        #if DEBUG
+        try await setupLaunchAgentForDebugBuildIfNeeded()
+        #else
         if #available(macOS 13, *) {
             try await setupLaunchAgent()
         } else {
@@ -33,6 +36,7 @@ public struct LaunchAgentManager {
             guard !FileManager.default.fileExists(atPath: launchAgentPath) else { return }
             try await setupLaunchAgent()
         }
+        #endif
     }
 
     public func setupLaunchAgent() async throws {
@@ -56,6 +60,9 @@ public struct LaunchAgentManager {
     }
 
     public func removeLaunchAgent() async throws {
+        #if DEBUG
+        Self.didRunInThisProcess = false
+        #endif
         if #available(macOS 13, *) {
             let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist")
             try? await bridgeLaunchAgent.unregister()
diff --git a/Core/Tests/LaunchAgentManagerTests/DebugLaunchAgentPlistTests.swift b/Core/Tests/LaunchAgentManagerTests/DebugLaunchAgentPlistTests.swift
new file mode 100644
index 00000000..7120f6d3
--- /dev/null
+++ b/Core/Tests/LaunchAgentManagerTests/DebugLaunchAgentPlistTests.swift
@@ -0,0 +1,48 @@
+import Foundation
+import XCTest
+
+@testable import LaunchAgentManager
+
+final class DebugLaunchAgentPlistTests: XCTestCase {
+    func test_xmlData_roundTripsProgramPathContainingAmpersand() throws {
+        let programPath = "/tmp/foo & bar/CommunicationBridge"
+        let data = try DebugLaunchAgentPlist.xmlData(
+            serviceIdentifier: "dev.com.intii.CopilotForXcode.CommunicationBridge",
+            programPath: programPath,
+            bundleIdentifier: "dev.com.intii.CopilotForXcode"
+        )
+
+        let xml = String(decoding: data, as: UTF8.self)
+        XCTAssertTrue(xml.contains("&"), "ampersand in the program path must be XML-escaped")
+        XCTAssertFalse(xml.contains("foo & bar"))
+
+        let parsed = try PropertyListSerialization.propertyList(from: data, options: [], format: nil)
+        let dict = try XCTUnwrap(parsed as? [String: Any])
+        XCTAssertEqual(dict["Program"] as? String, programPath)
+        XCTAssertEqual(
+            dict["Label"] as? String,
+            "dev.com.intii.CopilotForXcode.CommunicationBridge"
+        )
+    }
+
+    func test_propertyListsEqual_comparesParsedDictionaries() throws {
+        let first = try DebugLaunchAgentPlist.xmlData(
+            serviceIdentifier: "label",
+            programPath: "/tmp/foo & bar",
+            bundleIdentifier: "bundle"
+        )
+        let second = try DebugLaunchAgentPlist.xmlData(
+            serviceIdentifier: "label",
+            programPath: "/tmp/foo & bar",
+            bundleIdentifier: "bundle"
+        )
+        XCTAssertTrue(DebugLaunchAgentPlist.propertyListsEqual(first, second))
+
+        let different = try DebugLaunchAgentPlist.xmlData(
+            serviceIdentifier: "label",
+            programPath: "/tmp/other",
+            bundleIdentifier: "bundle"
+        )
+        XCTAssertFalse(DebugLaunchAgentPlist.propertyListsEqual(first, different))
+    }
+}
diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift
index 84e52a56..0d94e711 100644
--- a/ExtensionService/AppDelegate.swift
+++ b/ExtensionService/AppDelegate.swift
@@ -81,8 +81,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
 
     func setupQuitOnUpdate() {
         Task {
+            #if DEBUG
+            let fingerprint = ExecutableFingerprint.current()
+            #else
             guard let url = Bundle.main.executableURL else { return }
             let checker = await FileChangeChecker(fileURL: url)
+            #endif
 
             // If Xcode or Copilot for Xcode is made active, check if the executable of this program
             // is changed. If changed, quit this program.
@@ -95,7 +99,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
                     .userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
                     app.isUserOfService
                 else { continue }
-                guard await checker.checkIfChanged() else {
+                #if DEBUG
+                let changed = ExecutableFingerprint.current() != fingerprint
+                #else
+                let changed = await checker.checkIfChanged()
+                #endif
+                guard changed else {
                     Logger.service.info("Extension Service is not updated, no need to quit.")
                     continue
                 }
diff --git a/ExtensionService/ExecutableFingerprint.swift b/ExtensionService/ExecutableFingerprint.swift
new file mode 100644
index 00000000..1d2f8a8d
--- /dev/null
+++ b/ExtensionService/ExecutableFingerprint.swift
@@ -0,0 +1,19 @@
+#if DEBUG
+import Foundation
+
+/// Debug 包主可执行文件是 stub,代码在 `.debug.dylib`;指纹取 dylib 的 (mtime, size)。
+enum ExecutableFingerprint {
+    static func current() -> String {
+        guard let executableURL = Bundle.main.executableURL else { return "" }
+        let dylibPath = executableURL.path + ".debug.dylib"
+        let path = FileManager.default.fileExists(atPath: dylibPath) ? dylibPath : executableURL.path
+        guard let attributes = try? FileManager.default.attributesOfItem(atPath: path) else {
+            return path
+        }
+        let mtime = attributes[.modificationDate] as? Date
+        let size = (attributes[.size] as? NSNumber)?.int64Value ?? 0
+        let mtimeBits = mtime.map { String($0.timeIntervalSince1970.bitPattern) } ?? "0"
+        return "\(mtimeBits)-\(size)"
+    }
+}
+#endif
diff --git a/ExtensionService/XPCController.swift b/ExtensionService/XPCController.swift
index 5fdd4445..71aa16a6 100644
--- a/ExtensionService/XPCController.swift
+++ b/ExtensionService/XPCController.swift
@@ -28,7 +28,11 @@ final class XPCController: XPCServiceDelegate {
     func quit() async {
         bridge.setDelegate(nil)
         pingTask?.cancel()
+        #if DEBUG
+        // Kickstart restarts the rebuilt bridge; quit() would exit(0) and hit launchd's 10s throttle.
+        #else
         try? await bridge.quit()
+        #endif
     }
 
     deinit {
@@ -43,7 +47,11 @@ final class XPCController: XPCServiceDelegate {
                 guard let self else { return }
                 do {
                     try await self.bridge.updateServiceEndpoint(self.xpcListener.endpoint)
+                    #if DEBUG
+                    try await Task.sleep(nanoseconds: 5_000_000_000)
+                    #else
                     try await Task.sleep(nanoseconds: 60_000_000_000)
+                    #endif
                 } catch {
                     try await Task.sleep(nanoseconds: 1_000_000_000)
                     #if DEBUG
diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan
index 11113895..105058b7 100644
--- a/TestPlan.xctestplan
+++ b/TestPlan.xctestplan
@@ -50,6 +50,13 @@
         "name" : "ServiceUpdateMigrationTests"
       }
     },
+    {
+      "target" : {
+        "containerPath" : "container:Core",
+        "identifier" : "LaunchAgentManagerTests",
+        "name" : "LaunchAgentManagerTests"
+      }
+    },
     {
       "target" : {
         "containerPath" : "container:Tool",

From c57ded8f430ad5ba997c04183032bf53c5f3f2ff Mon Sep 17 00:00:00 2001
From: Skyler Liu <9743878+JayDaniel@users.noreply.github.com>
Date: Mon, 7 Sep 2026 20:03:03 -0700
Subject: [PATCH 11/16] Fix custom model settings state and validation

- Write back only the model fields when the edit sheet reads from
  disk, instead of rebuilding the API key and base URL pickers, which
  raced with an empty tag
- Give BuiltInSuggestionFeatureProvider.customModel the raw value
  1000. A persisted SuggestionFeatureProvider of Int 2 now fails to
  decode and falls back to the default provider, so the user has to
  pick Custom Model once
- Keep the key names of the standalone app without migrating its
  configuration, and treat the old chatModel value as an invalid model
- Snapshot modelId, chatModels and strategyId once in the coordinator,
  save through userDefaults behind a non-empty check, and forward
  cancellation to the inner task
- Populate the Completion picker from the completions list and the FIM
  picker from the codestral list, and drop the unreachable googleAI
  view
- Fold the three model field views into SharedModelFields, and move
  RequestStrategyOption and the model lists out of the engine, so
  HostApp no longer depends on CustomSuggestionService

Tests cover saving from CompletionModelEdit and the raw 1000 round
trip for SuggestionFeatureProvider. The sheet pickers were not
verified in the GUI.
---
 Core/Package.swift                            |  11 +-
 .../CompletionModelEdit.swift                 |  45 +++---
 .../CompletionModelEditView.swift             | 128 +++++-------------
 .../CustomModelSuggestionView.swift           |   1 -
 .../CustomModelSuggestion/FIMModelEdit.swift  |  44 +++---
 .../FIMModelEditView.swift                    |  92 ++++---------
 .../SharedModelFields.swift                   |  84 ++++++++++++
 .../TabbyModelEdit.swift                      |  34 +++--
 .../TabbyModelEditView.swift                  |  60 ++++----
 .../CompletionModelEditTests.swift            |  97 +++++++++++++
 .../SuggestionFeatureProviderTests.swift      |  22 +++
 TestPlan.xctestplan                           |   7 +
 Tool/Sources/AIModel/KnownModelNames.swift    |  72 ++++++++++
 .../API/GoogleGeminiService.swift             |  13 --
 .../API/OpenAIService.swift                   |  79 -----------
 .../CustomSuggestionCoordinator.swift         |  35 +++--
 .../RequestStrategy.swift                     |  10 +-
 .../CustomSuggestionServiceKeys.swift         |   3 +-
 .../Preferences/RequestStrategyOption.swift   |  10 ++
 .../Types/SuggestionFeatureProvider.swift     |   3 +-
 20 files changed, 501 insertions(+), 349 deletions(-)
 create mode 100644 Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/SharedModelFields.swift
 create mode 100644 Core/Tests/HostAppTests/CompletionModelEditTests.swift
 create mode 100644 Core/Tests/HostAppTests/SuggestionFeatureProviderTests.swift
 create mode 100644 Tool/Sources/AIModel/KnownModelNames.swift
 create mode 100644 Tool/Sources/Preferences/RequestStrategyOption.swift

diff --git a/Core/Package.swift b/Core/Package.swift
index 4c0b3f0d..348aaf3f 100644
--- a/Core/Package.swift
+++ b/Core/Package.swift
@@ -122,9 +122,9 @@ let package = Package(
         .target(
             name: "HostApp",
             dependencies: [
-                .product(name: "CustomSuggestionService", package: "Tool"),
                 "Client",
                 "LaunchAgentManager",
+                "UpdateChecker",
                 "PlusFeatureFlag",
                 .product(name: "SuggestionProvider", package: "Tool"),
                 .product(name: "Toast", package: "Tool"),
@@ -140,6 +140,15 @@ let package = Package(
                 "ProHostApp",
             ])
         ),
+        .testTarget(
+            name: "HostAppTests",
+            dependencies: [
+                "HostApp",
+                .product(name: "Preferences", package: "Tool"),
+                .product(name: "Toast", package: "Tool"),
+                .product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
+            ]
+        ),
 
         // MARK: - Suggestion Service
 
diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CompletionModelEdit.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CompletionModelEdit.swift
index d1ffb43b..c1350a4f 100644
--- a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CompletionModelEdit.swift
+++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CompletionModelEdit.swift
@@ -1,9 +1,7 @@
 import AIModel
 import ComposableArchitecture
-import CustomSuggestionService
 import Preferences
-import Dependencies
-import SwiftUI
+import Toast
 
 @Reducer
 struct CompletionModelEdit {
@@ -16,11 +14,11 @@ struct CompletionModelEdit {
         var apiKeyName: String { apiKeySelection.apiKeyName }
         var baseURL: String { baseURLSelection.baseURL }
         var availableModelNames: [String] = []
-        var availableAPIKeys: [String] = []
         var suggestedMaxTokens: Int?
         var apiKeySelection: APIKeySelection.State = .init()
         var baseURLSelection: BaseURLSelection.State = .init()
         var ollamaKeepAlive: String = ""
+        var didSave = false
     }
 
     enum Action: Equatable, BindableAction {
@@ -35,11 +33,7 @@ struct CompletionModelEdit {
     }
 
     @Dependency(\.toast) var toast
-    @Dependency(\.apiKeyKeychain) var keychain
-
-    enum DebounceID: Hashable {
-        case save
-    }
+    @Dependency(\.userDefaults) var userDefaults
 
     var body: some Reducer {
         BindingReducer()
@@ -55,27 +49,43 @@ struct CompletionModelEdit {
         Reduce { state, action in
             switch action {
             case .appear:
+                state.didSave = false
                 return .run { send in
                     await send(.readCustomModelFromDisk)
                 }
 
             case .saveButtonClicked:
-                let model = CompletionModel(state: state)
-                return .run { _ in
-                    UserDefaults.shared.set(model, for: \.customSuggestionCompletionModel)
+                let modelName = state.modelName.trimmingCharacters(in: .whitespacesAndNewlines)
+                let baseURL = state.baseURL.trimmingCharacters(in: .whitespacesAndNewlines)
+                guard !modelName.isEmpty else {
+                    toast("Model name cannot be empty", .error)
+                    return .none
                 }
+                guard !baseURL.isEmpty else {
+                    toast("Base URL cannot be empty", .error)
+                    return .none
+                }
+                userDefaults.set(CompletionModel(state: state), for: \.customSuggestionCompletionModel)
+                state.didSave = true
+                return .none
 
             case .refreshAvailableModelNames:
                 if state.format == .openAI {
-                    state.availableModelNames = CustomSuggestionService.OpenAIService.ChatCompletionModels.allCases
-                        .map(\.rawValue)
+                    state.availableModelNames = KnownCompletionModels.allCases.map(\.rawValue)
                 }
 
                 return .none
 
             case .readCustomModelFromDisk:
-                let model = UserDefaults.shared.value(for: \.customSuggestionCompletionModel)
-                state = model.toState()
+                let model = userDefaults.value(for: \.customSuggestionCompletionModel)
+                state.id = model.id
+                state.format = model.format
+                state.maxTokens = model.info.maxTokens
+                state.modelName = model.info.modelName
+                state.apiKeySelection.apiKeyName = model.info.apiKeyName
+                state.baseURLSelection.baseURL = model.info.baseURL
+                state.baseURLSelection.isFullURL = model.info.isFullURL
+                state.ollamaKeepAlive = model.info.ollamaInfo.keepAlive
 
                 return .run { send in
                     await send(.checkSuggestedMaxTokens)
@@ -85,7 +95,7 @@ struct CompletionModelEdit {
             case .checkSuggestedMaxTokens:
                 switch state.format {
                 case .openAI:
-                    if let knownModel = CustomSuggestionService.OpenAIService.CompletionModels(rawValue: state.modelName) {
+                    if let knownModel = KnownCompletionModels(rawValue: state.modelName) {
                         state.suggestedMaxTokens = knownModel.maxToken
                     } else {
                         state.suggestedMaxTokens = nil
@@ -153,4 +163,3 @@ extension CompletionModel {
         )
     }
 }
-
diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CompletionModelEditView.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CompletionModelEditView.swift
index 18694e04..df30963c 100644
--- a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CompletionModelEditView.swift
+++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CompletionModelEditView.swift
@@ -1,7 +1,5 @@
 import AIModel
 import ComposableArchitecture
-import CustomSuggestionService
-import Preferences
 import SwiftUI
 
 @MainActor
@@ -44,7 +42,6 @@ struct CompletionModelEditView: View {
 
                         Button(action: {
                             store.send(.saveButtonClicked)
-                            dismiss()
                         }) {
                             Text("Save")
                         }
@@ -57,6 +54,9 @@ struct CompletionModelEditView: View {
             .onAppear {
                 store.send(.appear)
             }
+            .onChange(of: store.didSave) { didSave in
+                if didSave { dismiss() }
+            }
             .fixedSize(horizontal: false, vertical: true)
         }
     }
@@ -93,7 +93,7 @@ struct CompletionModelEditView: View {
         prompt: Text?,
         @ViewBuilder trailingContent: @escaping () -> V
     ) -> some View {
-        BaseURLPicker(
+        BaseURLTextField(
             title: title,
             prompt: prompt,
             store: store.scope(
@@ -111,62 +111,15 @@ struct CompletionModelEditView: View {
         baseURLTextField(title: title, prompt: prompt, trailingContent: { EmptyView() })
     }
 
-    var maxTokensTextField: some View {
-        HStack {
-            let textFieldBinding = Binding(
-                get: { String(store.maxTokens) },
-                set: {
-                    if let selectionMaxToken = Int($0) {
-                        $store.maxTokens.wrappedValue = selectionMaxToken
-                    } else {
-                        $store.maxTokens.wrappedValue = 0
-                    }
-                }
-            )
-
-            TextField(text: textFieldBinding) {
-                Text("Context Window")
-                    .multilineTextAlignment(.trailing)
-            }
-            .overlay(alignment: .trailing) {
-                Stepper(
-                    value: $store.maxTokens,
-                    in: 0...Int.max,
-                    step: 100
-                ) {
-                    EmptyView()
-                }
-            }
-            .foregroundColor({
-                guard let max = store.suggestedMaxTokens else {
-                    return .primary
-                }
-                if store.maxTokens > max {
-                    return .red
-                }
-                return .primary
-            }() as Color)
-
-            if let max = store.suggestedMaxTokens {
-                Text("Max: \(max)")
-            }
-        }
-    }
-
-    @ViewBuilder
-    var apiKeyNamePicker: some View {
-        APIKeyPicker(store: store.scope(
-            state: \.apiKeySelection,
-            action: \.apiKeySelection
-        ))
-    }
-
     @ViewBuilder
     var openAI: some View {
         baseURLTextField(prompt: Text("https://api.openai.com")) {
             Text("/v1/completions")
         }
-        apiKeyNamePicker
+        ApiKeyNamePicker(store: store.scope(
+            state: \.apiKeySelection,
+            action: \.apiKeySelection
+        ))
 
         TextField("Model Name", text: $store.modelName)
             .overlay(alignment: .trailing) {
@@ -174,18 +127,21 @@ struct CompletionModelEditView: View {
                     "",
                     selection: $store.modelName,
                     content: {
-                        if CustomSuggestionService.OpenAIService.ChatCompletionModels(rawValue: store.modelName) == nil {
+                        if !store.availableModelNames.contains(store.modelName) {
                             Text("Custom Model").tag(store.modelName)
                         }
-                        ForEach(CustomSuggestionService.OpenAIService.ChatCompletionModels.allCases, id: \.self) { model in
-                            Text(model.rawValue).tag(model.rawValue)
+                        ForEach(store.availableModelNames, id: \.self) { model in
+                            Text(model).tag(model)
                         }
                     }
                 )
                 .frame(width: 20)
             }
 
-        maxTokensTextField
+        MaxTokensTextField(
+            maxTokens: $store.maxTokens,
+            suggestedMaxTokens: store.suggestedMaxTokens
+        )
 
         VStack(alignment: .leading, spacing: 8) {
             Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
@@ -198,11 +154,17 @@ struct CompletionModelEditView: View {
     @ViewBuilder
     var azureOpenAI: some View {
         baseURLTextField(prompt: Text("https://xxxx.openai.azure.com"))
-        apiKeyNamePicker
+        ApiKeyNamePicker(store: store.scope(
+            state: \.apiKeySelection,
+            action: \.apiKeySelection
+        ))
 
         TextField("Deployment Name", text: $store.modelName)
 
-        maxTokensTextField
+        MaxTokensTextField(
+            maxTokens: $store.maxTokens,
+            suggestedMaxTokens: store.suggestedMaxTokens
+        )
     }
 
     @ViewBuilder
@@ -227,35 +189,17 @@ struct CompletionModelEditView: View {
                 Text("/v1/completions")
             }
         }
-        apiKeyNamePicker
-
-        TextField("Model Name", text: $store.modelName)
-
-        maxTokensTextField
-    }
-
-    @ViewBuilder
-    var googleAI: some View {
-        apiKeyNamePicker
+        ApiKeyNamePicker(store: store.scope(
+            state: \.apiKeySelection,
+            action: \.apiKeySelection
+        ))
 
         TextField("Model Name", text: $store.modelName)
-            .overlay(alignment: .trailing) {
-                Picker(
-                    "",
-                    selection: $store.modelName,
-                    content: {
-                        if GoogleGeminiService.KnownModels(rawValue: store.modelName) == nil {
-                            Text("Custom Model").tag(store.modelName)
-                        }
-                        ForEach(GoogleGeminiService.KnownModels.allCases, id: \.self) { model in
-                            Text(model.rawValue).tag(model.rawValue)
-                        }
-                    }
-                )
-                .frame(width: 20)
-            }
 
-        maxTokensTextField
+        MaxTokensTextField(
+            maxTokens: $store.maxTokens,
+            suggestedMaxTokens: store.suggestedMaxTokens
+        )
     }
 
     @ViewBuilder
@@ -269,12 +213,15 @@ struct CompletionModelEditView: View {
 
         TextField("Model Name", text: $store.modelName)
 
-        maxTokensTextField
-        
+        MaxTokensTextField(
+            maxTokens: $store.maxTokens,
+            suggestedMaxTokens: store.suggestedMaxTokens
+        )
+
         TextField(text: $store.ollamaKeepAlive, prompt: Text("Default Value")) {
             Text("Keep Alive")
         }
-        
+
         VStack(alignment: .leading, spacing: 8) {
             Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
                 " For more details, please visit [https://ollama.com](https://ollama.com)"
@@ -321,4 +268,3 @@ struct CompletionModelEditView: View {
         )
     )
 }
-
diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift
index 0a7126f2..a11b97f9 100644
--- a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift
+++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift
@@ -1,6 +1,5 @@
 import AIModel
 import ComposableArchitecture
-import CustomSuggestionService
 import Preferences
 import SwiftUI
 
diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/FIMModelEdit.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/FIMModelEdit.swift
index ea7b1c2f..05a6217a 100644
--- a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/FIMModelEdit.swift
+++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/FIMModelEdit.swift
@@ -1,9 +1,7 @@
 import AIModel
 import ComposableArchitecture
-import CustomSuggestionService
 import Preferences
-import Dependencies
-import SwiftUI
+import Toast
 
 @Reducer
 struct FIMModelEdit {
@@ -16,13 +14,13 @@ struct FIMModelEdit {
         var apiKeyName: String { apiKeySelection.apiKeyName }
         var baseURL: String { baseURLSelection.baseURL }
         var availableModelNames: [String] = []
-        var availableAPIKeys: [String] = []
         var suggestedMaxTokens: Int?
         var apiKeySelection: APIKeySelection.State = .init()
         var baseURLSelection: BaseURLSelection.State = .init()
         var ollamaKeepAlive: String = ""
         var authenticationMode: FIMModel.Info.AuthenticationMode = .bearerToken
         var authenticationHeaderFieldName: String = ""
+        var didSave = false
     }
 
     enum Action: Equatable, BindableAction {
@@ -37,11 +35,7 @@ struct FIMModelEdit {
     }
 
     @Dependency(\.toast) var toast
-    @Dependency(\.apiKeyKeychain) var keychain
-
-    enum DebounceID: Hashable {
-        case save
-    }
+    @Dependency(\.userDefaults) var userDefaults
 
     var body: some Reducer {
         BindingReducer()
@@ -57,15 +51,25 @@ struct FIMModelEdit {
         Reduce { state, action in
             switch action {
             case .appear:
+                state.didSave = false
                 return .run { send in
                     await send(.readCustomModelFromDisk)
                 }
 
             case .saveButtonClicked:
-                let model = FIMModel(state: state)
-                return .run { _ in
-                    UserDefaults.shared.set(model, for: \.customSuggestionFIMModel)
+                let modelName = state.modelName.trimmingCharacters(in: .whitespacesAndNewlines)
+                let baseURL = state.baseURL.trimmingCharacters(in: .whitespacesAndNewlines)
+                guard !modelName.isEmpty else {
+                    toast("Model name cannot be empty", .error)
+                    return .none
                 }
+                guard !baseURL.isEmpty else {
+                    toast("Base URL cannot be empty", .error)
+                    return .none
+                }
+                userDefaults.set(FIMModel(state: state), for: \.customSuggestionFIMModel)
+                state.didSave = true
+                return .none
 
             case .refreshAvailableModelNames:
                 if state.format == .mistral {
@@ -78,8 +82,17 @@ struct FIMModelEdit {
                 return .none
 
             case .readCustomModelFromDisk:
-                let model = UserDefaults.shared.value(for: \.customSuggestionFIMModel)
-                state = model.toState()
+                let model = userDefaults.value(for: \.customSuggestionFIMModel)
+                state.id = model.id
+                state.format = model.format
+                state.maxTokens = model.info.maxTokens
+                state.modelName = model.info.modelName
+                state.apiKeySelection.apiKeyName = model.info.apiKeyName
+                state.baseURLSelection.baseURL = model.info.baseURL
+                state.baseURLSelection.isFullURL = model.info.isFullURL
+                state.ollamaKeepAlive = model.info.ollamaInfo.keepAlive
+                state.authenticationMode = model.info.authenticationMode
+                state.authenticationHeaderFieldName = model.info.authenticationHeaderFieldName
 
                 return .run { send in
                     await send(.checkSuggestedMaxTokens)
@@ -141,7 +154,7 @@ extension FIMModel {
     init(state: FIMModelEdit.State) {
         self.init(
             id: state.id,
-            name: "Custom Model (Completion API)",
+            name: "Custom Model (FIM API)",
             format: state.format,
             info: .init(
                 apiKeyName: state.apiKeyName,
@@ -156,4 +169,3 @@ extension FIMModel {
         )
     }
 }
-
diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/FIMModelEditView.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/FIMModelEditView.swift
index b81ceead..2eb75680 100644
--- a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/FIMModelEditView.swift
+++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/FIMModelEditView.swift
@@ -1,7 +1,5 @@
 import AIModel
 import ComposableArchitecture
-import CustomSuggestionService
-import Preferences
 import SwiftUI
 
 @MainActor
@@ -42,7 +40,6 @@ struct FIMModelEditView: View {
 
                         Button(action: {
                             store.send(.saveButtonClicked)
-                            dismiss()
                         }) {
                             Text("Save")
                         }
@@ -55,6 +52,9 @@ struct FIMModelEditView: View {
             .onAppear {
                 store.send(.appear)
             }
+            .onChange(of: store.didSave) { didSave in
+                if didSave { dismiss() }
+            }
             .fixedSize(horizontal: false, vertical: true)
         }
     }
@@ -89,7 +89,7 @@ struct FIMModelEditView: View {
         prompt: Text?,
         @ViewBuilder trailingContent: @escaping () -> V
     ) -> some View {
-        BaseURLPicker(
+        BaseURLTextField(
             title: title,
             prompt: prompt,
             store: store.scope(
@@ -107,56 +107,6 @@ struct FIMModelEditView: View {
         baseURLTextField(title: title, prompt: prompt, trailingContent: { EmptyView() })
     }
 
-    var maxTokensTextField: some View {
-        HStack {
-            let textFieldBinding = Binding(
-                get: { String(store.maxTokens) },
-                set: {
-                    if let selectionMaxToken = Int($0) {
-                        $store.maxTokens.wrappedValue = selectionMaxToken
-                    } else {
-                        $store.maxTokens.wrappedValue = 0
-                    }
-                }
-            )
-
-            TextField(text: textFieldBinding) {
-                Text("Context Window")
-                    .multilineTextAlignment(.trailing)
-            }
-            .overlay(alignment: .trailing) {
-                Stepper(
-                    value: $store.maxTokens,
-                    in: 0...Int.max,
-                    step: 100
-                ) {
-                    EmptyView()
-                }
-            }
-            .foregroundColor({
-                guard let max = store.suggestedMaxTokens else {
-                    return .primary
-                }
-                if store.maxTokens > max {
-                    return .red
-                }
-                return .primary
-            }() as Color)
-
-            if let max = store.suggestedMaxTokens {
-                Text("Max: \(max)")
-            }
-        }
-    }
-
-    @ViewBuilder
-    var apiKeyNamePicker: some View {
-        APIKeyPicker(store: store.scope(
-            state: \.apiKeySelection,
-            action: \.apiKeySelection
-        ))
-    }
-
     @ViewBuilder
     var mistralForm: some View {
         Picker(
@@ -179,7 +129,10 @@ struct FIMModelEditView: View {
                 Text("/v1/fim/completions")
             }
         }
-        apiKeyNamePicker
+        ApiKeyNamePicker(store: store.scope(
+            state: \.apiKeySelection,
+            action: \.apiKeySelection
+        ))
 
         TextField("Model Name", text: $store.modelName)
             .overlay(alignment: .trailing) {
@@ -187,19 +140,22 @@ struct FIMModelEditView: View {
                     "",
                     selection: $store.modelName,
                     content: {
-                        if CustomSuggestionService.OpenAIService.ChatCompletionModels(rawValue: store.modelName) == nil {
+                        if !store.availableModelNames.contains(store.modelName) {
                             Text("Custom Model").tag(store.modelName)
                         }
-                        ForEach(CustomSuggestionService.OpenAIService.ChatCompletionModels.allCases, id: \.self) { model in
-                            Text(model.rawValue).tag(model.rawValue)
+                        ForEach(store.availableModelNames, id: \.self) { model in
+                            Text(model).tag(model)
                         }
                     }
                 )
                 .frame(width: 20)
             }
 
-        maxTokensTextField
-        
+        MaxTokensTextField(
+            maxTokens: $store.maxTokens,
+            suggestedMaxTokens: store.suggestedMaxTokens
+        )
+
         VStack(alignment: .leading, spacing: 8) {
             Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
                 """
@@ -226,7 +182,10 @@ struct FIMModelEditView: View {
 
         TextField("Model Name", text: $store.modelName)
 
-        maxTokensTextField
+        MaxTokensTextField(
+            maxTokens: $store.maxTokens,
+            suggestedMaxTokens: store.suggestedMaxTokens
+        )
 
         TextField(text: $store.ollamaKeepAlive, prompt: Text("Default Value")) {
             Text("Keep Alive")
@@ -277,11 +236,17 @@ struct FIMModelEditView: View {
             TextField("Header Field Name", text: $store.authenticationHeaderFieldName)
         }
 
-        apiKeyNamePicker
+        ApiKeyNamePicker(store: store.scope(
+            state: \.apiKeySelection,
+            action: \.apiKeySelection
+        ))
 
         TextField("Model Name", text: $store.modelName)
 
-        maxTokensTextField
+        MaxTokensTextField(
+            maxTokens: $store.maxTokens,
+            suggestedMaxTokens: store.suggestedMaxTokens
+        )
 
         VStack(alignment: .leading, spacing: 8) {
             Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
@@ -310,4 +275,3 @@ struct FIMModelEditView: View {
         )
     )
 }
-
diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/SharedModelFields.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/SharedModelFields.swift
new file mode 100644
index 00000000..f8df25af
--- /dev/null
+++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/SharedModelFields.swift
@@ -0,0 +1,84 @@
+import ComposableArchitecture
+import SwiftUI
+
+struct MaxTokensTextField: View {
+    @Binding var maxTokens: Int
+    var suggestedMaxTokens: Int?
+
+    var body: some View {
+        HStack {
+            let textFieldBinding = Binding(
+                get: { String(maxTokens) },
+                set: {
+                    if let selectionMaxToken = Int($0) {
+                        maxTokens = selectionMaxToken
+                    } else {
+                        maxTokens = 0
+                    }
+                }
+            )
+
+            TextField(text: textFieldBinding) {
+                Text("Context Window")
+                    .multilineTextAlignment(.trailing)
+            }
+            .overlay(alignment: .trailing) {
+                Stepper(
+                    value: $maxTokens,
+                    in: 0...Int.max,
+                    step: 100
+                ) {
+                    EmptyView()
+                }
+            }
+            .foregroundColor({
+                guard let max = suggestedMaxTokens else {
+                    return .primary
+                }
+                if maxTokens > max {
+                    return .red
+                }
+                return .primary
+            }() as Color)
+
+            if let max = suggestedMaxTokens {
+                Text("Max: \(max)")
+            }
+        }
+    }
+}
+
+struct BaseURLTextField: View {
+    var title: String = "Base URL"
+    var prompt: Text?
+    let store: StoreOf
+    @ViewBuilder var trailingContent: () -> TrailingContent
+
+    var body: some View {
+        BaseURLPicker(
+            title: title,
+            prompt: prompt,
+            store: store,
+            trailingContent: trailingContent
+        )
+    }
+}
+
+extension BaseURLTextField where TrailingContent == EmptyView {
+    init(
+        title: String = "Base URL",
+        prompt: Text?,
+        store: StoreOf
+    ) {
+        self.init(title: title, prompt: prompt, store: store, trailingContent: { EmptyView() })
+    }
+}
+
+struct ApiKeyNamePicker: View {
+    let store: StoreOf
+    var title: String = "API Key"
+
+    var body: some View {
+        APIKeyPicker(store: store, title: title)
+    }
+}
diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/TabbyModelEdit.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/TabbyModelEdit.swift
index 9e4f1e3c..b6285c7e 100644
--- a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/TabbyModelEdit.swift
+++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/TabbyModelEdit.swift
@@ -1,9 +1,8 @@
 import AIModel
 import ComposableArchitecture
-import CustomSuggestionService
+import Foundation
 import Preferences
-import Dependencies
-import SwiftUI
+import Toast
 
 @Reducer
 struct TabbyModelEdit {
@@ -16,6 +15,7 @@ struct TabbyModelEdit {
         var authorizationMode: TabbyModel.AuthorizationMode
         var authorizationHeaderName: String
         var username: String
+        var didSave = false
     }
 
     enum Action: Equatable, BindableAction {
@@ -28,11 +28,7 @@ struct TabbyModelEdit {
     }
 
     @Dependency(\.toast) var toast
-    @Dependency(\.apiKeyKeychain) var keychain
-
-    enum DebounceID: Hashable {
-        case save
-    }
+    @Dependency(\.userDefaults) var userDefaults
 
     var body: some Reducer {
         BindingReducer()
@@ -48,19 +44,28 @@ struct TabbyModelEdit {
         Reduce { state, action in
             switch action {
             case .appear:
+                state.didSave = false
                 return .run { send in
                     await send(.readCustomModelFromDisk)
                 }
 
             case .saveButtonClicked:
-                let model = TabbyModel(state: state)
-                return .run { _ in
-                    UserDefaults.shared.set(model, for: \.customSuggestionTabbyModel)
+                let urlString = state.url.trimmingCharacters(in: .whitespacesAndNewlines)
+                guard let url = URL(string: urlString), url.scheme != nil, url.host != nil else {
+                    toast("URL is invalid", .error)
+                    return .none
                 }
+                userDefaults.set(TabbyModel(state: state), for: \.customSuggestionTabbyModel)
+                state.didSave = true
+                return .none
 
             case .readCustomModelFromDisk:
-                let model = UserDefaults.shared.value(for: \.customSuggestionTabbyModel)
-                state = model.toState()
+                let model = userDefaults.value(for: \.customSuggestionTabbyModel)
+                state.apiKeySelection.apiKeyName = model.apiKeyName
+                state.urlSelection.baseURL = model.url
+                state.authorizationMode = model.authorizationMode
+                state.authorizationHeaderName = model.authorizationHeaderName
+                state.username = model.username
                 return .none
 
             case .apiKeySelection:
@@ -93,7 +98,7 @@ extension TabbyModel {
 
     init(state: TabbyModelEdit.State) {
         self = .init(
-            url: state.urlSelection.baseURL,
+            url: state.urlSelection.baseURL.trimmingCharacters(in: .whitespacesAndNewlines),
             authorizationMode: state.authorizationMode,
             apiKeyName: state.apiKeySelection.apiKeyName,
             authorizationHeaderName: state.authorizationHeaderName,
@@ -101,4 +106,3 @@ extension TabbyModel {
         )
     }
 }
-
diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/TabbyModelEditView.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/TabbyModelEditView.swift
index 287da334..4492cfcb 100644
--- a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/TabbyModelEditView.swift
+++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/TabbyModelEditView.swift
@@ -1,7 +1,5 @@
 import AIModel
 import ComposableArchitecture
-import CustomSuggestionService
-import Preferences
 import SwiftUI
 
 @MainActor
@@ -31,7 +29,6 @@ struct TabbyModelEditView: View {
 
                         Button(action: {
                             store.send(.saveButtonClicked)
-                            dismiss()
                         }) {
                             Text("Save")
                         }
@@ -44,6 +41,9 @@ struct TabbyModelEditView: View {
             .onAppear {
                 store.send(.appear)
             }
+            .onChange(of: store.didSave) { didSave in
+                if didSave { dismiss() }
+            }
             .fixedSize(horizontal: false, vertical: true)
         }
     }
@@ -73,33 +73,16 @@ struct TabbyModelEditView: View {
         .pickerStyle(.segmented)
     }
 
-    func urlTextField(
-        title: String = "URL",
-        prompt: Text?
-    ) -> some View {
-        BaseURLPicker(
-            title: title,
-            prompt: prompt,
+    @ViewBuilder
+    var form: some View {
+        BaseURLTextField(
+            title: "URL",
+            prompt: Text("http://127.0.0.1:8080/v1/completions"),
             store: store.scope(
                 state: \.urlSelection,
                 action: \.urlSelection
             )
-        ) {
-            EmptyView()
-        }
-    }
-
-    @ViewBuilder
-    func apiKeyNamePicker(title: String = "API Key") -> some View {
-        APIKeyPicker(store: store.scope(
-            state: \.apiKeySelection,
-            action: \.apiKeySelection
-        ), title: title)
-    }
-
-    @ViewBuilder
-    var form: some View {
-        urlTextField(prompt: Text("http://127.0.0.1:8080/v1/completions"))
+        )
         authorizationModePicker
 
         switch store.authorizationMode {
@@ -107,12 +90,30 @@ struct TabbyModelEditView: View {
             EmptyView()
         case .basic:
             TextField("Username", text: $store.username)
-            apiKeyNamePicker(title: "Password")
+            ApiKeyNamePicker(
+                store: store.scope(
+                    state: \.apiKeySelection,
+                    action: \.apiKeySelection
+                ),
+                title: "Password"
+            )
         case .customHeaderField:
             TextField("Header Name", text: $store.authorizationHeaderName)
-            apiKeyNamePicker(title: "Value")
+            ApiKeyNamePicker(
+                store: store.scope(
+                    state: \.apiKeySelection,
+                    action: \.apiKeySelection
+                ),
+                title: "Value"
+            )
         case .bearerToken:
-            apiKeyNamePicker(title: "Token")
+            ApiKeyNamePicker(
+                store: store.scope(
+                    state: \.apiKeySelection,
+                    action: \.apiKeySelection
+                ),
+                title: "Token"
+            )
         }
     }
 }
@@ -164,4 +165,3 @@ struct TabbyModelEditView: View {
         )
     )
 }
-
diff --git a/Core/Tests/HostAppTests/CompletionModelEditTests.swift b/Core/Tests/HostAppTests/CompletionModelEditTests.swift
new file mode 100644
index 00000000..ca329044
--- /dev/null
+++ b/Core/Tests/HostAppTests/CompletionModelEditTests.swift
@@ -0,0 +1,97 @@
+import AIModel
+import ComposableArchitecture
+import Foundation
+import Preferences
+import Toast
+import XCTest
+
+@testable import HostApp
+
+@MainActor
+final class CompletionModelEditTests: XCTestCase {
+    func test_saveButtonClicked_emptyModelName_doesNotWrite() async {
+        let (defaults, suite) = isolatedDefaults()
+        defer { defaults.removePersistentDomain(forName: suite) }
+
+        let store = TestStore(
+            initialState: CompletionModelEdit.State(
+                format: .openAI,
+                modelName: "  ",
+                baseURLSelection: .init(baseURL: "https://api.openai.com")
+            )
+        ) {
+            CompletionModelEdit()
+        } withDependencies: {
+            $0.userDefaults = defaults
+            $0.toastController = ToastController(messages: [])
+        }
+
+        await store.send(.saveButtonClicked)
+        XCTAssertNil(
+            defaults.value(forKey: UserDefaultPreferenceKeys().customSuggestionCompletionModel.key)
+        )
+        XCTAssertFalse(store.state.didSave)
+    }
+
+    func test_saveButtonClicked_emptyBaseURL_doesNotWrite() async {
+        let (defaults, suite) = isolatedDefaults()
+        defer { defaults.removePersistentDomain(forName: suite) }
+
+        let store = TestStore(
+            initialState: CompletionModelEdit.State(
+                format: .openAI,
+                modelName: "gpt-3.5-turbo-instruct",
+                baseURLSelection: .init(baseURL: "")
+            )
+        ) {
+            CompletionModelEdit()
+        } withDependencies: {
+            $0.userDefaults = defaults
+            $0.toastController = ToastController(messages: [])
+        }
+
+        await store.send(.saveButtonClicked)
+        XCTAssertNil(
+            defaults.value(forKey: UserDefaultPreferenceKeys().customSuggestionCompletionModel.key)
+        )
+        XCTAssertFalse(store.state.didSave)
+    }
+
+    func test_saveButtonClicked_writesViaUserDefaults() async {
+        let (defaults, suite) = isolatedDefaults()
+        defer { defaults.removePersistentDomain(forName: suite) }
+
+        let store = TestStore(
+            initialState: CompletionModelEdit.State(
+                id: "custom-id",
+                format: .openAI,
+                maxTokens: 2048,
+                modelName: "gpt-3.5-turbo-instruct",
+                baseURLSelection: .init(baseURL: "https://api.openai.com")
+            )
+        ) {
+            CompletionModelEdit()
+        } withDependencies: {
+            $0.userDefaults = defaults
+            $0.toastController = ToastController(messages: [])
+        }
+
+        await store.send(.saveButtonClicked) {
+            $0.didSave = true
+        }
+
+        let saved = defaults.value(for: \.customSuggestionCompletionModel)
+        XCTAssertEqual(saved.id, "custom-id")
+        XCTAssertEqual(saved.info.modelName, "gpt-3.5-turbo-instruct")
+        XCTAssertEqual(saved.info.baseURL, "https://api.openai.com")
+        XCTAssertEqual(saved.info.maxTokens, 2048)
+        XCTAssertEqual(saved.format, .openAI)
+    }
+}
+
+private func isolatedDefaults() -> (UserDefaults, String) {
+    let suite = "CompletionModelEditTests-\(UUID().uuidString)"
+    let defaults = UserDefaults(suiteName: suite)!
+    defaults.removePersistentDomain(forName: suite)
+    return (defaults, suite)
+}
diff --git a/Core/Tests/HostAppTests/SuggestionFeatureProviderTests.swift b/Core/Tests/HostAppTests/SuggestionFeatureProviderTests.swift
new file mode 100644
index 00000000..1f9ae2dd
--- /dev/null
+++ b/Core/Tests/HostAppTests/SuggestionFeatureProviderTests.swift
@@ -0,0 +1,22 @@
+import Preferences
+import XCTest
+
+final class SuggestionFeatureProviderTests: XCTestCase {
+    func test_customModel_rawValue_is_1000() {
+        XCTAssertEqual(BuiltInSuggestionFeatureProvider.customModel.rawValue, 1000)
+        XCTAssertEqual(BuiltInSuggestionFeatureProvider(rawValue: 1000), .customModel)
+        XCTAssertNil(BuiltInSuggestionFeatureProvider(rawValue: 2))
+    }
+
+    func test_suggestionFeatureProvider_roundTrip_builtInCustomModel() throws {
+        let provider = SuggestionFeatureProvider.builtIn(.customModel)
+        XCTAssertEqual(SuggestionFeatureProvider(rawValue: provider.rawValue), provider)
+
+        let encoded = try JSONEncoder().encode(BuiltInSuggestionFeatureProvider.customModel)
+        XCTAssertEqual(String(data: encoded, encoding: .utf8), "1000")
+
+        XCTAssertThrowsError(
+            try JSONDecoder().decode(BuiltInSuggestionFeatureProvider.self, from: Data("2".utf8))
+        )
+    }
+}
diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan
index 105058b7..bece20a5 100644
--- a/TestPlan.xctestplan
+++ b/TestPlan.xctestplan
@@ -196,6 +196,13 @@
         "identifier" : "CustomSuggestionServiceTests",
         "name" : "CustomSuggestionServiceTests"
       }
+    },
+    {
+      "target" : {
+        "containerPath" : "container:Core",
+        "identifier" : "HostAppTests",
+        "name" : "HostAppTests"
+      }
     }
   ],
   "version" : 1
diff --git a/Tool/Sources/AIModel/KnownModelNames.swift b/Tool/Sources/AIModel/KnownModelNames.swift
new file mode 100644
index 00000000..b8f93b3d
--- /dev/null
+++ b/Tool/Sources/AIModel/KnownModelNames.swift
@@ -0,0 +1,72 @@
+import Foundation
+
+/// Display names for custom-suggestion model dropdowns. Not a live provider catalog.
+public enum KnownChatCompletionModels: String, CaseIterable {
+    case gpt55 = "gpt-5.5"
+    case gpt54 = "gpt-5.4"
+    case gpt41 = "gpt-4.1"
+    case gpt41Mini = "gpt-4.1-mini"
+    case gpt41Nano = "gpt-4.1-nano"
+    case gpt4o = "gpt-4o"
+    case gpt4oMini = "gpt-4o-mini"
+    case gpt4Turbo = "gpt-4-turbo"
+    case gpt4 = "gpt-4"
+    case o3 = "o3"
+    case o3Mini = "o3-mini"
+    case o4Mini = "o4-mini"
+    case o1 = "o1"
+    case o1Pro = "o1-pro"
+    case o1Preview = "o1-preview"
+    case gpt35Turbo = "gpt-3.5-turbo"
+
+    public var maxToken: Int {
+        switch self {
+        case .gpt55, .gpt54, .gpt41, .gpt41Mini, .gpt41Nano:
+            return 1_047_576
+        case .gpt4o, .gpt4oMini, .gpt4Turbo:
+            return 128_000
+        case .gpt4:
+            return 8192
+        case .o3, .o3Mini, .o4Mini, .o1, .o1Pro:
+            return 200_000
+        case .o1Preview:
+            return 128_000
+        case .gpt35Turbo:
+            return 16385
+        }
+    }
+}
+
+/// Completions-endpoint names (`/v1/completions`), not chat completions.
+public enum KnownCompletionModels: String, CaseIterable {
+    case gpt35TurboInstruct = "gpt-3.5-turbo-instruct"
+
+    public var maxToken: Int {
+        switch self {
+        case .gpt35TurboInstruct:
+            return 4096
+        }
+    }
+}
+
+public enum KnownGeminiModels: String, CaseIterable {
+    case gemini25FlashPreview = "gemini-2.5-flash-preview-04-17"
+    case gemini25ProPreview = "gemini-2.5-pro-preview-05-06"
+    case gemini20Flash = "gemini-2.0-flash"
+    case gemini20FlashLite = "gemini-2.0-flash-lite"
+    case gemini15Pro = "gemini-1.5-pro"
+    case gemini15Flash = "gemini-1.5-flash"
+    case geminiPro = "gemini-pro"
+
+    public var maxToken: Int {
+        switch self {
+        case .geminiPro:
+            return 32768
+        case .gemini15Pro:
+            return 2_097_152
+        case .gemini25FlashPreview, .gemini25ProPreview, .gemini20Flash, .gemini20FlashLite,
+             .gemini15Flash:
+            return 1_048_576
+        }
+    }
+}
diff --git a/Tool/Sources/CustomSuggestionService/API/GoogleGeminiService.swift b/Tool/Sources/CustomSuggestionService/API/GoogleGeminiService.swift
index 64066864..10792637 100644
--- a/Tool/Sources/CustomSuggestionService/API/GoogleGeminiService.swift
+++ b/Tool/Sources/CustomSuggestionService/API/GoogleGeminiService.swift
@@ -57,19 +57,6 @@ extension GoogleGeminiService: CodeCompletionServiceType {
     }
 }
 
-public extension GoogleGeminiService {
-    enum KnownModels: String, CaseIterable {
-        case geminiPro = "gemini-pro"
-
-        public var maxToken: Int {
-            switch self {
-            case .geminiPro:
-                return 32768
-            }
-        }
-    }
-}
-
 extension GoogleGeminiService {
     public enum Error: Swift.Error, LocalizedError {
         case apiError(Swift.Error)
diff --git a/Tool/Sources/CustomSuggestionService/API/OpenAIService.swift b/Tool/Sources/CustomSuggestionService/API/OpenAIService.swift
index 57b0490b..be498053 100644
--- a/Tool/Sources/CustomSuggestionService/API/OpenAIService.swift
+++ b/Tool/Sources/CustomSuggestionService/API/OpenAIService.swift
@@ -579,82 +579,3 @@ extension OpenAIService {
         var choices: [Choice]?
     }
 }
-
-// MARK: - Models
-
-public extension OpenAIService {
-    public enum ChatCompletionModels: String, CaseIterable {
-        case gpt35Turbo = "gpt-3.5-turbo"
-        case gpt35Turbo16k = "gpt-3.5-turbo-16k"
-        case gpt4 = "gpt-4"
-        case gpt432k = "gpt-4-32k"
-        case gpt4TurboPreview = "gpt-4-turbo-preview"
-        case gpt40314 = "gpt-4-0314"
-        case gpt40613 = "gpt-4-0613"
-        case gpt41106Preview = "gpt-4-1106-preview"
-        case gpt4VisionPreview = "gpt-4-vision-preview"
-        case gpt35Turbo0301 = "gpt-3.5-turbo-0301"
-        case gpt35Turbo0613 = "gpt-3.5-turbo-0613"
-        case gpt35Turbo1106 = "gpt-3.5-turbo-1106"
-        case gpt35Turbo0125 = "gpt-3.5-turbo-0125"
-        case gpt35Turbo16k0613 = "gpt-3.5-turbo-16k-0613"
-        case gpt432k0314 = "gpt-4-32k-0314"
-        case gpt432k0613 = "gpt-4-32k-0613"
-        case gpt40125 = "gpt-4-0125-preview"
-    }
-
-    public enum CompletionModels: String, CaseIterable {
-        case gpt35TurboInstruct = "gpt-3.5-turbo-instruct"
-    }
-}
-
-public extension OpenAIService.ChatCompletionModels {
-    public var maxToken: Int {
-        switch self {
-        case .gpt4:
-            return 8192
-        case .gpt40314:
-            return 8192
-        case .gpt432k:
-            return 32768
-        case .gpt432k0314:
-            return 32768
-        case .gpt35Turbo:
-            return 4096
-        case .gpt35Turbo0301:
-            return 4096
-        case .gpt35Turbo0613:
-            return 4096
-        case .gpt35Turbo1106:
-            return 16385
-        case .gpt35Turbo0125:
-            return 16385
-        case .gpt35Turbo16k:
-            return 16385
-        case .gpt35Turbo16k0613:
-            return 16385
-        case .gpt40613:
-            return 8192
-        case .gpt432k0613:
-            return 32768
-        case .gpt41106Preview:
-            return 128_000
-        case .gpt4VisionPreview:
-            return 128_000
-        case .gpt4TurboPreview:
-            return 128_000
-        case .gpt40125:
-            return 128_000
-        }
-    }
-}
-
-public extension OpenAIService.CompletionModels {
-    public var maxToken: Int {
-        switch self {
-        case .gpt35TurboInstruct:
-            return 4096
-        }
-    }
-}
-
diff --git a/Tool/Sources/CustomSuggestionService/CustomSuggestionCoordinator.swift b/Tool/Sources/CustomSuggestionService/CustomSuggestionCoordinator.swift
index ce2aebae..1afb0254 100644
--- a/Tool/Sources/CustomSuggestionService/CustomSuggestionCoordinator.swift
+++ b/Tool/Sources/CustomSuggestionService/CustomSuggestionCoordinator.swift
@@ -40,6 +40,11 @@ actor CustomSuggestionCoordinator {
     ) async throws -> [CodeSuggestion] {
         onGoingTask?.cancel()
         onGoingTask = nil
+        let snapshot = PreferenceSnapshot(
+            modelId: UserDefaults.shared.value(for: \.customSuggestionModelId),
+            chatModels: UserDefaults.shared.value(for: \.chatModels),
+            strategyId: UserDefaults.shared.value(for: \.customSuggestionRequestStrategyId)
+        )
         let task = Task {
             try await CodeCompletionLogger.$logger.withValue(.init(request: request)) {
                 do {
@@ -52,7 +57,8 @@ actor CustomSuggestionCoordinator {
                     let strategy = getStrategy(
                         sourceRequest: request,
                         prefix: previousLines,
-                        suffix: nextLines
+                        suffix: nextLines,
+                        snapshot: snapshot
                     )
 
                     if strategy.shouldSkip {
@@ -60,7 +66,7 @@ actor CustomSuggestionCoordinator {
                     }
 
                     let service = CodeCompletionService()
-                    let model = try getModel()
+                    let model = try getModel(snapshot)
                     let prompt = strategy.createPrompt()
                     let postProcessor = strategy.createRawSuggestionPostProcessor()
                     let stopStream = strategy.createStreamStopStrategy(model: model)
@@ -139,12 +145,22 @@ actor CustomSuggestionCoordinator {
             }
         }
         onGoingTask = task
-        return try await task.value
+        return try await withTaskCancellationHandler {
+            try await task.value
+        } onCancel: {
+            task.cancel()
+        }
+    }
+
+    struct PreferenceSnapshot {
+        var modelId: String
+        var chatModels: [ChatModel]
+        var strategyId: String
     }
 
-    func getModel() throws -> Model {
-        let id = UserDefaults.shared.value(for: \.customSuggestionModelId)
-        let models = UserDefaults.shared.value(for: \.chatModels)
+    func getModel(_ snapshot: PreferenceSnapshot) throws -> Model {
+        let id = snapshot.modelId
+        let models = snapshot.chatModels
         if let existedModel = models.first(where: { $0.id == id }) {
             return .chatModel(existedModel)
         }
@@ -176,10 +192,11 @@ actor CustomSuggestionCoordinator {
     func getStrategy(
         sourceRequest: SuggestionRequest,
         prefix: [String],
-        suffix: [String]
+        suffix: [String],
+        snapshot: PreferenceSnapshot
     ) -> any RequestStrategy {
-        let id = UserDefaults.shared.value(for: \.customSuggestionRequestStrategyId)
-        let type = CustomModelType(rawValue: UserDefaults.shared.value(for: \.customSuggestionModelId))
+        let id = snapshot.strategyId
+        let type = CustomModelType(rawValue: snapshot.modelId)
         if let type, type == .tabby {
             return TabbyRequestStrategy(
                 sourceRequest: sourceRequest,
diff --git a/Tool/Sources/CustomSuggestionService/RequestStrategy.swift b/Tool/Sources/CustomSuggestionService/RequestStrategy.swift
index 9a27104d..b1fee7cf 100644
--- a/Tool/Sources/CustomSuggestionService/RequestStrategy.swift
+++ b/Tool/Sources/CustomSuggestionService/RequestStrategy.swift
@@ -1,6 +1,7 @@
 import CopilotForXcodeKit
 import Foundation
 import AIModel
+import Preferences
 
 /// Prompts may behave differently in different LLMs.
 /// This protocol allows for different strategies to be used to generate prompts.
@@ -25,15 +26,6 @@ protocol RequestStrategy {
     func createRawSuggestionPostProcessor() -> RawSuggestionPostProcessor
 }
 
-public enum RequestStrategyOption: String, CaseIterable, Codable {
-    case `default` = ""
-    case naive
-    case `continue`
-    case codeLlamaFillInTheMiddle
-    case codeLlamaFillInTheMiddleWithSystemPrompt
-    case anthropic
-}
-
 extension RequestStrategyOption {
     var strategy: any RequestStrategy.Type {
         switch self {
diff --git a/Tool/Sources/Preferences/CustomSuggestionServiceKeys.swift b/Tool/Sources/Preferences/CustomSuggestionServiceKeys.swift
index 9b4c3233..2d0d00b4 100644
--- a/Tool/Sources/Preferences/CustomSuggestionServiceKeys.swift
+++ b/Tool/Sources/Preferences/CustomSuggestionServiceKeys.swift
@@ -3,8 +3,7 @@ import Foundation
 
 /// Preference keys of the built-in custom model suggestion provider.
 ///
-/// The raw key strings keep the `CustomSuggestionService-` prefix used by the original standalone
-/// "Custom Suggestion Service for Copilot for Xcode" app.
+/// 键名沿用独立 App,但不迁移其配置;旧值 `chatModel` 视为无效模型。
 public extension UserDefaultPreferenceKeys {
     /// Either a `CustomModelType` raw value, or the id of a chat model in ``chatModels``.
     var customSuggestionModelId: PreferenceKey {
diff --git a/Tool/Sources/Preferences/RequestStrategyOption.swift b/Tool/Sources/Preferences/RequestStrategyOption.swift
new file mode 100644
index 00000000..2b8cc854
--- /dev/null
+++ b/Tool/Sources/Preferences/RequestStrategyOption.swift
@@ -0,0 +1,10 @@
+import Foundation
+
+public enum RequestStrategyOption: String, CaseIterable, Codable {
+    case `default` = ""
+    case naive
+    case `continue`
+    case codeLlamaFillInTheMiddle
+    case codeLlamaFillInTheMiddleWithSystemPrompt
+    case anthropic
+}
diff --git a/Tool/Sources/Preferences/Types/SuggestionFeatureProvider.swift b/Tool/Sources/Preferences/Types/SuggestionFeatureProvider.swift
index 28b8c01a..190337a5 100644
--- a/Tool/Sources/Preferences/Types/SuggestionFeatureProvider.swift
+++ b/Tool/Sources/Preferences/Types/SuggestionFeatureProvider.swift
@@ -3,7 +3,8 @@ import Foundation
 public enum BuiltInSuggestionFeatureProvider: Int, CaseIterable, Codable {
     case gitHubCopilot
     case codeium
-    case customModel
+    /// Not the next sequential Int: a persisted `2` from earlier builds will fail to decode.
+    case customModel = 1000
 }
 
 public enum SuggestionFeatureProvider: RawRepresentable, Hashable {

From 6c00a42253335afb77db66514c7600c801983988 Mon Sep 17 00:00:00 2001
From: Skyler Liu <9743878+JayDaniel@users.noreply.github.com>
Date: Mon, 7 Sep 2026 20:38:02 -0700
Subject: [PATCH 12/16] Fold Azure into OpenAIService and drop dead code

- Give OpenAIService an authentication mode, bearer or api-key, and an
  Azure endpoint builder; route .azureOpenAI through it and delete
  AzureOpenAIService
- The Azure path now inherits SSELine, custom headers, the reasoning
  shape with max_completion_tokens, and non-streaming parsing
- Delete the unused APIError, decodeError and countToken from the
  OpenAI and Ollama services, and Models and validateResponse from
  Anthropic
- Log CompletionModel under the Completion type, and fall back to
  http://127.0.0.1:11434 for Ollama
- Show the reasoning effort and token budget on the Azure settings
  page, move the chat api-version to 2024-09-01-preview, and fail on
  an empty Azure URL rather than calling api.openai.com

Tests assert that an Azure reasoning request carries api-key,
max_completion_tokens, /openai/deployments and api-version.
CustomSuggestionServiceTests passes with 56 cases.
---
 .../CustomModelSuggestionView.swift           |   7 +-
 Tool/Sources/AIModel/ChatModel.swift          |   2 +-
 .../API/AnthropicService.swift                |  24 ---
 .../API/AzureOpenAIService.swift              | 168 ------------------
 .../API/OllamaService.swift                   |   8 +-
 .../API/OpenAIService.swift                   |  98 +++++++---
 .../CodeCompletionLogger.swift                |   2 +-
 .../CodeCompletionService.swift               |  14 +-
 .../ChatAPIOptionsAndSemanticsTests.swift     |  98 ++++++++++
 .../ChatAPIStreamParsingTests.swift           |  97 +++++++++-
 10 files changed, 285 insertions(+), 233 deletions(-)
 delete mode 100644 Tool/Sources/CustomSuggestionService/API/AzureOpenAIService.swift

diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift
index a11b97f9..e15d80e7 100644
--- a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift
+++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift
@@ -231,7 +231,10 @@ struct ChatAPIOptionsPicker: View {
         let format = selectedModel?.format
         let currentOptions = options
         let showsEndpoint = format == .openAI || format == .openAICompatible
-        let showsEffort = format == .openAI || format == .openAICompatible || format == .claude
+        let showsEffort = format == .openAI || format == .openAICompatible
+            || format == .claude || format == .azureOpenAI
+        let showsBudget = format == .openAI || format == .openAICompatible
+            || format == .azureOpenAI
 
         if showsEndpoint {
             Picker(
@@ -271,7 +274,7 @@ struct ChatAPIOptionsPicker: View {
             }
         }
 
-        if showsEndpoint, currentOptions.reasoningEffort != nil {
+        if showsBudget, currentOptions.reasoningEffort != nil {
             NumberInput(
                 value: Binding(
                     get: { currentOptions.reasoningTokenBudget },
diff --git a/Tool/Sources/AIModel/ChatModel.swift b/Tool/Sources/AIModel/ChatModel.swift
index 145d0298..5472d479 100644
--- a/Tool/Sources/AIModel/ChatModel.swift
+++ b/Tool/Sources/AIModel/ChatModel.swift
@@ -180,7 +180,7 @@ public struct ChatModel: Codable, Equatable, Identifiable {
         case .azureOpenAI:
             let baseURL = info.baseURL
             let deployment = info.modelName
-            let version = "2024-02-15-preview"
+            let version = "2024-09-01-preview"
             if baseURL.isEmpty { return "" }
             return "\(baseURL)/openai/deployments/\(deployment)/chat/completions?api-version=\(version)"
         case .googleAI:
diff --git a/Tool/Sources/CustomSuggestionService/API/AnthropicService.swift b/Tool/Sources/CustomSuggestionService/API/AnthropicService.swift
index 2b19d329..55c687ba 100644
--- a/Tool/Sources/CustomSuggestionService/API/AnthropicService.swift
+++ b/Tool/Sources/CustomSuggestionService/API/AnthropicService.swift
@@ -40,20 +40,6 @@ public actor AnthropicService {
         self.stopWords = stopWords
         self.contextWindow = contextWindow
     }
-
-    public enum Models: String, CaseIterable {
-        case claude3Opus = "claude-3-opus-latest"
-        case claude35Sonnet = "claude-3-5-sonnet-latest"
-        case claude35Haiku = "claude-3-5-haiku-latest"
-
-        public var maxToken: Int {
-            switch self {
-            case .claude3Opus: return 200_000
-            case .claude35Sonnet: return 200_000
-            case .claude35Haiku: return 200_000
-            }
-        }
-    }
 }
 
 // MARK: - CodeCompletionServiceType Implementation
@@ -293,13 +279,3 @@ extension AnthropicService {
         }
     }
 }
-
-// MARK: - Helper Methods
-
-extension AnthropicService {
-    func validateResponse(_ response: HTTPURLResponse) throws {
-        guard (200 ... 299).contains(response.statusCode) else {
-            throw Error.otherError("HTTP Error: \(response.statusCode)")
-        }
-    }
-}
diff --git a/Tool/Sources/CustomSuggestionService/API/AzureOpenAIService.swift b/Tool/Sources/CustomSuggestionService/API/AzureOpenAIService.swift
deleted file mode 100644
index 7fd36af1..00000000
--- a/Tool/Sources/CustomSuggestionService/API/AzureOpenAIService.swift
+++ /dev/null
@@ -1,168 +0,0 @@
-import CopilotForXcodeKit
-import Foundation
-import AIModel
-
-public actor AzureOpenAIService {
-    let url: URL
-    let endpoint: OpenAIService.Endpoint
-    let modelName: String
-    let contextWindow: Int
-    let maxToken: Int
-    let temperature: Double
-    let apiKey: String
-    let stopWords: [String]
-    let extraHeaders: [(name: String, value: String)]
-
-    init(
-        url: String? = nil,
-        endpoint: OpenAIService.Endpoint,
-        modelName: String,
-        contextWindow: Int,
-        maxToken: Int,
-        temperature: Double = 0.2,
-        stopWords: [String] = [],
-        apiKey: String,
-        extraHeaders: [(name: String, value: String)] = []
-    ) {
-        self.url = url.flatMap(URL.init(string:)) ?? {
-            switch endpoint {
-            case .chatCompletion:
-                URL(string: "https://api.openai.com/v1/chat/completions")!
-            case .completion:
-                URL(string: "https://api.openai.com/v1/completions")!
-            }
-        }()
-
-        self.endpoint = endpoint
-        self.modelName = modelName
-        self.maxToken = maxToken
-        self.contextWindow = contextWindow
-        self.temperature = temperature
-        self.stopWords = stopWords
-        self.apiKey = apiKey
-        self.extraHeaders = extraHeaders
-    }
-}
-
-extension AzureOpenAIService: CodeCompletionServiceType {
-    typealias CompletionSequence = AsyncThrowingCompactMapSequence<
-        ResponseStream,
-        String
-    >
-
-    func getCompletion(_ request: PromptStrategy) async throws -> CompletionSequence {
-        switch endpoint {
-        case .chatCompletion:
-            let messages = createMessages(from: request)
-            CodeCompletionLogger.logger.logPrompt(messages.map {
-                ($0.content, $0.role.rawValue)
-            })
-            let result = try await sendMessages(messages)
-            return result.compactMap { $0.choices?.first?.delta?.content }
-        case .completion:
-            let prompt = createPrompt(from: request)
-            CodeCompletionLogger.logger.logPrompt([(prompt, "user")])
-            let result = try await sendPrompt(prompt)
-            return result.compactMap { $0.choices?.first?.delta?.content }
-        }
-    }
-}
-
-extension AzureOpenAIService {
-    public typealias Message = OpenAIService.Message
-    public typealias APIError = OpenAIService.APIError
-    public typealias Error = OpenAIService.Error
-
-    func createMessages(from request: PromptStrategy) -> [Message] {
-        let strategy = DefaultTruncateStrategy(maxTokenLimit: max(
-            contextWindow / 3 * 2,
-            contextWindow - maxToken - 20
-        ))
-        let prompts = strategy.createTruncatedPrompt(promptStrategy: request)
-        return [
-            .init(role: .system, content: request.systemPrompt),
-        ] + prompts.map { prompt in
-            switch prompt.role {
-            case .user:
-                return .init(role: .user, content: prompt.content)
-            case .assistant:
-                return .init(role: .assistant, content: prompt.content)
-            }
-        }
-    }
-
-    func sendMessages(
-        _ messages: [Message]
-    ) async throws -> ResponseStream {
-        let requestBody = OpenAIService.ChatCompletionRequestBody(
-            model: modelName,
-            messages: messages,
-            temperature: temperature,
-            stream: true,
-            stop: stopWords,
-            max_tokens: maxToken
-        )
-
-        var request = URLRequest(url: url)
-        request.httpMethod = "POST"
-        let encoder = JSONEncoder()
-        request.httpBody = try encoder.encode(requestBody)
-        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
-        request.setValue(apiKey, forHTTPHeaderField: "api-key")
-        request.setExtraHeaders(extraHeaders)
-        let (result, response) = try await URLSession.shared.bytes(for: request)
-        return try await CompletionHTTP.makeStream(
-            result: result,
-            response: response,
-            mapError: { Error.otherError($0) },
-            parseBody: { try OpenAIService.parseChatCompletionsBody($0, url: url) },
-            parseLine: OpenAIService.parseChatCompletionsStreamLine
-        )
-    }
-
-    func createPrompt(from request: PromptStrategy) -> String {
-        let strategy = DefaultTruncateStrategy(maxTokenLimit: max(
-            contextWindow / 3 * 2,
-            contextWindow - maxToken - 20
-        ))
-        let prompts = strategy.createTruncatedPrompt(promptStrategy: request)
-        return ([request.systemPrompt] + prompts.map(\.content)).joined(separator: "\n\n")
-    }
-
-    func sendPrompt(
-        _ prompt: String
-    ) async throws -> ResponseStream {
-        let requestBody = OpenAIService.CompletionRequestBody(
-            model: modelName,
-            prompt: prompt,
-            temperature: temperature,
-            stream: true,
-            stop: stopWords,
-            max_tokens: maxToken
-        )
-
-        var request = URLRequest(url: url)
-        request.httpMethod = "POST"
-        let encoder = JSONEncoder()
-        request.httpBody = try encoder.encode(requestBody)
-        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
-        request.setValue(apiKey, forHTTPHeaderField: "api-key")
-        request.setExtraHeaders(extraHeaders)
-        let (result, response) = try await URLSession.shared.bytes(for: request)
-        return try await CompletionHTTP.makeStream(
-            result: result,
-            response: response,
-            mapError: { Error.otherError($0) },
-            parseBody: { try OpenAIService.asChatChunk(OpenAIService.parseCompletionsBody($0, url: url)) },
-            parseLine: { line in
-                let content = try OpenAIService.parseCompletionsStreamLine(line)
-                return .init(chunk: content.chunk.map(OpenAIService.asChatChunk), done: content.done)
-            }
-        )
-    }
-
-    func countToken(_ message: Message) -> Int {
-        message.content.count
-    }
-}
-
diff --git a/Tool/Sources/CustomSuggestionService/API/OllamaService.swift b/Tool/Sources/CustomSuggestionService/API/OllamaService.swift
index 864a7953..54ca3c2e 100644
--- a/Tool/Sources/CustomSuggestionService/API/OllamaService.swift
+++ b/Tool/Sources/CustomSuggestionService/API/OllamaService.swift
@@ -47,9 +47,9 @@ public actor OllamaService {
         self.url = url.flatMap(URL.init(string:)) ?? {
             switch endpoint {
             case .chatCompletion:
-                URL(string: "https://127.0.0.1:11434/api/chat")!
+                URL(string: "http://127.0.0.1:11434/api/chat")!
             case .completion, .completionWithSuffix:
-                URL(string: "https://127.0.0.1:11434/api/generate")!
+                URL(string: "http://127.0.0.1:11434/api/generate")!
             }
         }()
 
@@ -308,9 +308,5 @@ extension OllamaService {
             parseLine: Self.parseStreamLine
         )
     }
-
-    func countToken(_ message: Message) -> Int {
-        message.content.count
-    }
 }
 
diff --git a/Tool/Sources/CustomSuggestionService/API/OpenAIService.swift b/Tool/Sources/CustomSuggestionService/API/OpenAIService.swift
index be498053..55b63a71 100644
--- a/Tool/Sources/CustomSuggestionService/API/OpenAIService.swift
+++ b/Tool/Sources/CustomSuggestionService/API/OpenAIService.swift
@@ -23,12 +23,18 @@ public actor OpenAIService {
     let customJSONBody: String
     let requiresBeginWithUserMessage: Bool
     let enforceMessageOrder: Bool
+    let authentication: Authentication
 
     public enum Endpoint {
         case completion
         case chatCompletion
     }
 
+    public enum Authentication {
+        case bearer
+        case apiKeyHeader
+    }
+
     init(
         url: String? = nil,
         endpoint: Endpoint,
@@ -38,6 +44,7 @@ public actor OpenAIService {
         temperature: Double = 0.2,
         stopWords: [String] = [],
         apiKey: String,
+        authentication: Authentication = .bearer,
         reasoningEffort: ReasoningEffort? = nil,
         reasoningTokenBudget: Int = 1500,
         extraHeaders: [(name: String, value: String)] = [],
@@ -46,7 +53,8 @@ public actor OpenAIService {
         customJSONBody: String = "",
         requiresBeginWithUserMessage: Bool = false,
         enforceMessageOrder: Bool = false
-    ) {
+    ) throws {
+        self.authentication = authentication
         self.reasoningEffort = reasoningEffort
         self.reasoningTokenBudget = reasoningTokenBudget
         self.extraHeaders = extraHeaders
@@ -55,14 +63,11 @@ public actor OpenAIService {
         self.customJSONBody = customJSONBody
         self.requiresBeginWithUserMessage = requiresBeginWithUserMessage
         self.enforceMessageOrder = enforceMessageOrder
-        self.url = url.flatMap(URL.init(string:)) ?? {
-            switch endpoint {
-            case .chatCompletion:
-                URL(string: "https://api.openai.com/v1/chat/completions")!
-            case .completion:
-                URL(string: "https://api.openai.com/v1/completions")!
-            }
-        }()
+        self.url = try Self.resolveURL(
+            url: url,
+            authentication: authentication,
+            endpoint: endpoint
+        )
 
         self.endpoint = endpoint
         self.modelName = modelName
@@ -72,6 +77,54 @@ public actor OpenAIService {
         self.stopWords = stopWords
         self.contextWindow = contextWindow
     }
+
+    nonisolated static let missingAzureEndpointMessage =
+        "Azure OpenAI endpoint is empty. Set the resource base URL."
+
+    /// Matches `ChatModel.endpoint` / `CompletionModel.endpoint` Azure branches.
+    nonisolated static func azureEndpoint(
+        baseURL: String,
+        deployment: String,
+        endpoint: Endpoint
+    ) -> String {
+        switch endpoint {
+        case .chatCompletion:
+            let version = "2024-09-01-preview"
+            if baseURL.isEmpty { return "" }
+            return "\(baseURL)/openai/deployments/\(deployment)/chat/completions?api-version=\(version)"
+        case .completion:
+            let version = "2023-07-01-preview"
+            if baseURL.isEmpty { return "" }
+            return "\(baseURL)/openai/deployments/\(deployment)/completions?api-version=\(version)"
+        }
+    }
+
+    /// Azure (`api-key`) must not fall back to api.openai.com.
+    nonisolated static func resolveURL(
+        url: String?,
+        authentication: Authentication,
+        endpoint: Endpoint
+    ) throws -> URL {
+        if let url, !url.isEmpty, let parsed = URL(string: url) {
+            if authentication == .apiKeyHeader, isOpenAIAPIHost(parsed) {
+                throw Error.otherError(missingAzureEndpointMessage)
+            }
+            return parsed
+        }
+        if authentication == .apiKeyHeader {
+            throw Error.otherError(missingAzureEndpointMessage)
+        }
+        switch endpoint {
+        case .chatCompletion:
+            return URL(string: "https://api.openai.com/v1/chat/completions")!
+        case .completion:
+            return URL(string: "https://api.openai.com/v1/completions")!
+        }
+    }
+
+    nonisolated static func isOpenAIAPIHost(_ url: URL) -> Bool {
+        url.host == "api.openai.com"
+    }
 }
 
 extension OpenAIService: CodeCompletionServiceType {
@@ -99,31 +152,17 @@ extension OpenAIService: CodeCompletionServiceType {
 }
 
 public extension OpenAIService {
-    struct APIError: Decodable {
-        var message: String
-        var type: String
-        var param: String
-        var code: String
-    }
-
     enum Error: Swift.Error, LocalizedError {
-        case decodeError(Swift.Error)
         case apiError(String)
         case otherError(String)
 
         public var errorDescription: String? {
             switch self {
-            case let .decodeError(error):
-                return error.localizedDescription
             case let .apiError(message), let .otherError(message):
                 return message
             }
         }
     }
-
-    func countToken(_ message: Message) -> Int {
-        message.content.count
-    }
 }
 
 // MARK: - Chat Completion API
@@ -223,7 +262,7 @@ extension OpenAIService {
             jsonBody: customJSONBody
         )
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
-        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+        applyAuthentication(to: &request)
         request.setOpenAIIdentityHeaders(organizationID: organizationID, projectID: projectID)
         request.setExtraHeaders(extraHeaders)
         let (result, response) = try await URLSession.shared.bytes(for: request)
@@ -236,6 +275,15 @@ extension OpenAIService {
         )
     }
 
+    func applyAuthentication(to request: inout URLRequest) {
+        switch authentication {
+        case .bearer:
+            request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+        case .apiKeyHeader:
+            request.setValue(apiKey, forHTTPHeaderField: "api-key")
+        }
+    }
+
     /// Parses a non-streaming `application/json` chat completions body.
     static func parseChatCompletionsBody(
         _ text: String,
@@ -452,7 +500,7 @@ extension OpenAIService {
             jsonBody: customJSONBody
         )
         request.setValue("application/json", forHTTPHeaderField: "Content-Type")
-        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+        applyAuthentication(to: &request)
         request.setOpenAIIdentityHeaders(organizationID: organizationID, projectID: projectID)
         request.setExtraHeaders(extraHeaders)
         let (result, response) = try await URLSession.shared.bytes(for: request)
diff --git a/Tool/Sources/CustomSuggestionService/CodeCompletionLogger.swift b/Tool/Sources/CustomSuggestionService/CodeCompletionLogger.swift
index 1a2f95fd..fb2399c1 100644
--- a/Tool/Sources/CustomSuggestionService/CodeCompletionLogger.swift
+++ b/Tool/Sources/CustomSuggestionService/CodeCompletionLogger.swift
@@ -55,7 +55,7 @@ public final class CodeCompletionLogger {
 
     public func logModel(_ completionModel: CompletionModel) {
         model = .init(
-            type: "Chat Completion",
+            type: "Completion",
             format: completionModel.format.rawValue,
             modelName: completionModel.info.modelName,
             baseURL: completionModel.info.baseURL
diff --git a/Tool/Sources/CustomSuggestionService/CodeCompletionService.swift b/Tool/Sources/CustomSuggestionService/CodeCompletionService.swift
index 2572a057..c847db65 100644
--- a/Tool/Sources/CustomSuggestionService/CodeCompletionService.swift
+++ b/Tool/Sources/CustomSuggestionService/CodeCompletionService.swift
@@ -153,7 +153,7 @@ public struct CodeCompletionService {
                 try Task.checkCancellation()
                 return result
             case .chatCompletions:
-                let service = OpenAIService(
+                let service = try OpenAIService(
                     url: model.endpoint,
                     endpoint: .chatCompletion,
                     modelName: model.info.modelName,
@@ -180,7 +180,7 @@ public struct CodeCompletionService {
                 return result
             }
         case .azureOpenAI:
-            let service = AzureOpenAIService(
+            let service = try OpenAIService(
                 url: model.endpoint,
                 endpoint: .chatCompletion,
                 modelName: model.info.modelName,
@@ -188,6 +188,9 @@ public struct CodeCompletionService {
                 maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken),
                 stopWords: prompt.stopWords,
                 apiKey: apiKey,
+                authentication: .apiKeyHeader,
+                reasoningEffort: options.reasoningEffort,
+                reasoningTokenBudget: options.reasoningTokenBudget,
                 extraHeaders: options.extraHeaders
             )
             let result = try await service.getCompletions(
@@ -266,7 +269,7 @@ public struct CodeCompletionService {
 
         switch model.format {
         case .openAI, .openAICompatible:
-            let service = OpenAIService(
+            let service = try OpenAIService(
                 url: model.endpoint,
                 endpoint: .completion,
                 modelName: model.info.modelName,
@@ -283,14 +286,15 @@ public struct CodeCompletionService {
             try Task.checkCancellation()
             return result
         case .azureOpenAI:
-            let service = AzureOpenAIService(
+            let service = try OpenAIService(
                 url: model.endpoint,
                 endpoint: .completion,
                 modelName: model.info.modelName,
                 contextWindow: model.info.maxTokens,
                 maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken),
                 stopWords: prompt.stopWords,
-                apiKey: apiKey
+                apiKey: apiKey,
+                authentication: .apiKeyHeader
             )
             let result = try await service.getCompletions(
                 prompt,
diff --git a/Tool/Tests/CustomSuggestionServiceTests/ChatAPIOptionsAndSemanticsTests.swift b/Tool/Tests/CustomSuggestionServiceTests/ChatAPIOptionsAndSemanticsTests.swift
index a913cb67..b6f2e177 100644
--- a/Tool/Tests/CustomSuggestionServiceTests/ChatAPIOptionsAndSemanticsTests.swift
+++ b/Tool/Tests/CustomSuggestionServiceTests/ChatAPIOptionsAndSemanticsTests.swift
@@ -238,6 +238,104 @@ final class ChatAPIOptionsAndSemanticsTests: XCTestCase {
         XCTAssertEqual(result, "hello")
     }
 
+    func test_azure_format_reasoning_request_body_contains_max_completion_tokens() throws {
+        let chat = ChatModel(
+            id: "azure-chat",
+            name: "Azure Chat",
+            format: .azureOpenAI,
+            info: .init(
+                apiKeyName: "azure",
+                baseURL: "https://example.openai.azure.com",
+                maxTokens: 8000,
+                modelName: "gpt-4o"
+            )
+        )
+        XCTAssertEqual(
+            chat.endpoint,
+            OpenAIService.azureEndpoint(
+                baseURL: chat.info.baseURL,
+                deployment: chat.info.modelName,
+                endpoint: .chatCompletion
+            )
+        )
+        XCTAssertEqual(
+            OpenAIService.azureEndpoint(
+                baseURL: "",
+                deployment: "gpt-4o",
+                endpoint: .chatCompletion
+            ),
+            ""
+        )
+        XCTAssertEqual(
+            try OpenAIService.resolveURL(
+                url: chat.endpoint,
+                authentication: .apiKeyHeader,
+                endpoint: .chatCompletion
+            ).absoluteString,
+            chat.endpoint
+        )
+        XCTAssertTrue(chat.endpoint.contains("api-version=2024-09-01-preview"))
+
+        XCTAssertThrowsError(
+            try OpenAIService.resolveURL(
+                url: "",
+                authentication: .apiKeyHeader,
+                endpoint: .chatCompletion
+            )
+        ) { error in
+            XCTAssertEqual(
+                error.localizedDescription,
+                OpenAIService.missingAzureEndpointMessage
+            )
+        }
+        XCTAssertThrowsError(
+            try OpenAIService.resolveURL(
+                url: "https://api.openai.com/v1/chat/completions",
+                authentication: .apiKeyHeader,
+                endpoint: .chatCompletion
+            )
+        ) { error in
+            XCTAssertEqual(
+                error.localizedDescription,
+                OpenAIService.missingAzureEndpointMessage
+            )
+        }
+
+        let completion = CompletionModel(
+            id: "azure-completion",
+            name: "Azure Completion",
+            format: .azureOpenAI,
+            info: .init(
+                baseURL: "https://example.openai.azure.com",
+                modelName: "gpt-35-turbo-instruct"
+            )
+        )
+        XCTAssertEqual(
+            completion.endpoint,
+            OpenAIService.azureEndpoint(
+                baseURL: completion.info.baseURL,
+                deployment: completion.info.modelName,
+                endpoint: .completion
+            )
+        )
+
+        let body = OpenAIService.makeChatCompletionRequestBody(
+            modelName: chat.info.modelName,
+            messages: [.init(role: .user, content: "hi")],
+            temperature: 0.2,
+            stopWords: ["\n\n"],
+            maxToken: 200,
+            reasoningEffort: .high,
+            reasoningTokenBudget: 1500
+        )
+        let json = try jsonObject(body)
+        XCTAssertEqual((json["max_completion_tokens"] as? NSNumber)?.intValue, 1700)
+        XCTAssertEqual(json["reasoning_effort"] as? String, "high")
+        XCTAssertNil(json["max_tokens"])
+        XCTAssertNil(json["temperature"])
+        XCTAssertNil(json["stop"])
+    }
+
     func test_customBodyInfo_joinJSON_merge() throws {
         let original = Data(#"{"model":"a","stream":true}"#.utf8)
         let merged = CompletionJSON.mergeCustomBody(
diff --git a/Tool/Tests/CustomSuggestionServiceTests/ChatAPIStreamParsingTests.swift b/Tool/Tests/CustomSuggestionServiceTests/ChatAPIStreamParsingTests.swift
index 67d0b785..e1af8a97 100644
--- a/Tool/Tests/CustomSuggestionServiceTests/ChatAPIStreamParsingTests.swift
+++ b/Tool/Tests/CustomSuggestionServiceTests/ChatAPIStreamParsingTests.swift
@@ -167,7 +167,7 @@ class ChatAPIStreamParsingTests: XCTestCase {
         URLProtocol.registerClass(CompletionStubURLProtocol.self)
         defer { CompletionStubURLProtocol.reset() }
 
-        let service = OpenAIService(
+        let service = try OpenAIService(
             url: "http://\(host)/v1/chat/completions",
             endpoint: .chatCompletion,
             modelName: "gpt-5.5",
@@ -193,6 +193,85 @@ class ChatAPIStreamParsingTests: XCTestCase {
         task.cancel()
     }
 
+    func test_azure_reasoning_request_sends_api_key_header_and_max_completion_tokens() async throws {
+        let host = "wp6-azure-\(UUID().uuidString).invalid"
+        final class Capture: @unchecked Sendable {
+            var request: URLRequest?
+        }
+        let capture = Capture()
+        CompletionStubURLProtocol.host = host
+        CompletionStubURLProtocol.handler = { request in
+            capture.request = request
+            let sse = #"data: {"choices":[{"index":0,"delta":{"content":"ok"},"finish_reason":"stop"}]}"#
+                + "\n\ndata: [DONE]\n"
+            return (200, ["Content-Type": "text/event-stream"], Data(sse.utf8))
+        }
+        URLProtocol.registerClass(CompletionStubURLProtocol.self)
+        defer { CompletionStubURLProtocol.reset() }
+
+        let model = ChatModel(
+            id: "azure",
+            name: "Azure",
+            format: .azureOpenAI,
+            info: .init(
+                apiKeyName: "azure",
+                baseURL: "http://\(host)",
+                modelName: "gpt-4o"
+            )
+        )
+        // Same initializer as CodeCompletionService `.azureOpenAI` chat.
+        let service = try OpenAIService(
+            url: model.endpoint,
+            endpoint: .chatCompletion,
+            modelName: model.info.modelName,
+            contextWindow: 8000,
+            maxToken: 200,
+            apiKey: "azure-key",
+            authentication: .apiKeyHeader,
+            reasoningEffort: .high,
+            reasoningTokenBudget: 1500
+        )
+        let didFinish = expectation(description: "azure stream")
+        let task = Task {
+            do {
+                let stream = try await service.getCompletion(ErrorVisibilityPrompt())
+                var text = ""
+                for try await part in stream {
+                    text += part
+                }
+                XCTAssertEqual(text, "ok")
+            } catch {
+                XCTFail("unexpected error: \(error)")
+            }
+            didFinish.fulfill()
+        }
+        await fulfillment(of: [didFinish], timeout: 2)
+        task.cancel()
+
+        let request = try XCTUnwrap(capture.request)
+        XCTAssertEqual(request.value(forHTTPHeaderField: "api-key"), "azure-key")
+        XCTAssertNil(request.value(forHTTPHeaderField: "Authorization"))
+        let requestURL = try XCTUnwrap(request.url)
+        XCTAssertTrue(
+            requestURL.path.contains("/openai/deployments/"),
+            requestURL.path
+        )
+        XCTAssertTrue(
+            requestURL.path.hasSuffix("/chat/completions"),
+            requestURL.path
+        )
+        XCTAssertEqual(
+            URLComponents(url: requestURL, resolvingAgainstBaseURL: false)?
+                .queryItems?.first(where: { $0.name == "api-version" })?.value,
+            "2024-09-01-preview"
+        )
+        let body = try XCTUnwrap(httpBody(of: request))
+        let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any])
+        XCTAssertEqual((json["max_completion_tokens"] as? NSNumber)?.intValue, 1700)
+        XCTAssertEqual(json["reasoning_effort"] as? String, "high")
+        XCTAssertNil(json["max_tokens"])
+    }
+
     func test_tabby_http_500_throws_immediately() async {
         let host = "wp2-tabby-\(UUID().uuidString).invalid"
         CompletionStubURLProtocol.host = host
@@ -275,6 +354,22 @@ private struct ErrorVisibilityPrompt: PromptStrategy {
     }
 }
 
+private func httpBody(of request: URLRequest) -> Data? {
+    if let body = request.httpBody { return body }
+    guard let stream = request.httpBodyStream else { return nil }
+    stream.open()
+    defer { stream.close() }
+    var data = Data()
+    let buffer = UnsafeMutablePointer.allocate(capacity: 4096)
+    defer { buffer.deallocate() }
+    while stream.hasBytesAvailable {
+        let count = stream.read(buffer, maxLength: 4096)
+        if count <= 0 { break }
+        data.append(buffer, count: count)
+    }
+    return data
+}
+
 private final class CompletionStubURLProtocol: URLProtocol {
     static var host: String?
     static var handler: ((URLRequest) throws -> (Int, [String: String], Data))?

From 259d7a3d9a2650a9b94ae4d1cb9c7b93d7adb52d Mon Sep 17 00:00:00 2001
From: Skyler Liu <9743878+JayDaniel@users.noreply.github.com>
Date: Mon, 7 Sep 2026 21:05:24 -0700
Subject: [PATCH 13/16] Verify peer code signatures on XPC listeners

- Set a same-team code signing requirement on the CommunicationBridge
  and ExtensionService listeners before resume, and refuse the
  connection when the team id is unreadable or the requirement invalid
- Derive the team id by dropping the trailing dot from
  TEAM_ID_PREFIX; the bridge reads it from the Info.plist of the
  neighbouring CopilotForXcodeExtensionService.app
- Accept updateServiceEndpoint only from a process whose bundle id is
  .ExtensionService, and log the rest
- Say in the verbose log switch that it writes prompts containing your
  source code to Console.app
- Log the requirement string on refusal, rather than a localized
  description that is null

A signed debug build passes with TEAM_ID_PREFIX=94G4SCKS9Z. The
version row on the General page, and the refusal of a client from
another team, were not verified in the GUI.
---
 CommunicationBridge/ServiceDelegate.swift     | 26 +++++++++++++++++
 CommunicationBridge/main.swift                |  8 +++--
 .../CustomModelSuggestionView.swift           |  5 +++-
 ExtensionService/AppDelegate.swift            |  1 +
 ExtensionService/ServiceDelegate.swift        | 17 +++++++++++
 Tool/Package.swift                            |  5 +++-
 .../XPCShared/XPCPeerRequirement.swift        | 29 +++++++++++++++++++
 7 files changed, 87 insertions(+), 4 deletions(-)
 create mode 100644 Tool/Sources/XPCShared/XPCPeerRequirement.swift

diff --git a/CommunicationBridge/ServiceDelegate.swift b/CommunicationBridge/ServiceDelegate.swift
index a42f64b4..3ca279a7 100644
--- a/CommunicationBridge/ServiceDelegate.swift
+++ b/CommunicationBridge/ServiceDelegate.swift
@@ -8,6 +8,22 @@ class ServiceDelegate: NSObject, NSXPCListenerDelegate {
         _: NSXPCListener,
         shouldAcceptNewConnection newConnection: NSXPCConnection
     ) -> Bool {
+        guard let teamID = XPCPeerRequirement.teamID(fromPrefix: teamIDPrefix) else {
+            Logger.communicationBridge.error(
+                "Rejected XPC connection from pid \(newConnection.processIdentifier): Team ID is unavailable."
+            )
+            return false
+        }
+        let requirement = XPCPeerRequirement.codeSigningRequirement(teamID: teamID)
+        do {
+            try XPCPeerRequirement.setCodeSigningRequirement(on: newConnection, teamID: teamID)
+        } catch {
+            Logger.communicationBridge.error(
+                "Rejected XPC connection from pid \(newConnection.processIdentifier): invalid code signing requirement (\(requirement))."
+            )
+            return false
+        }
+
         newConnection.exportedInterface = NSXPCInterface(
             with: CommunicationBridgeXPCServiceProtocol.self
         )
@@ -107,6 +123,16 @@ actor EventHandler {
         withReply reply: () -> Void
     ) {
         rescheduleExitTask()
+        let expectedBundleID = bundleIdentifierBase + ".ExtensionService"
+        let actualBundleID = NSRunningApplication(processIdentifier: processIdentifier)?
+            .bundleIdentifier
+        guard actualBundleID == expectedBundleID else {
+            Logger.communicationBridge.error(
+                "Ignoring service endpoint from pid \(processIdentifier) (\(actualBundleID ?? "unknown bundle id")); expected \(expectedBundleID)."
+            )
+            reply()
+            return
+        }
         self.endpoint = endpoint
         endpointPID = processIdentifier
         reply()
diff --git a/CommunicationBridge/main.swift b/CommunicationBridge/main.swift
index bb449566..91cb6156 100644
--- a/CommunicationBridge/main.swift
+++ b/CommunicationBridge/main.swift
@@ -3,9 +3,13 @@ import Foundation
 
 class AppDelegate: NSObject, NSApplicationDelegate {}
 
-let bundleIdentifierBase = Bundle(url: Bundle.main.bundleURL.appendingPathComponent(
+let extensionServiceBundle = Bundle(url: Bundle.main.bundleURL.appendingPathComponent(
     "CopilotForXcodeExtensionService.app"
-))?.object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as? String ?? "com.intii.CopilotForXcode"
+))
+let bundleIdentifierBase = extensionServiceBundle?
+    .object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as? String ?? "com.intii.CopilotForXcode"
+let teamIDPrefix = extensionServiceBundle?
+    .object(forInfoDictionaryKey: "TEAM_ID_PREFIX") as? String
 
 let serviceIdentifier = bundleIdentifierBase + ".CommunicationBridge"
 let appDelegate = AppDelegate()
diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift
index e15d80e7..2961eb9f 100644
--- a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift
+++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift
@@ -68,7 +68,10 @@ struct CustomModelSuggestionView: View {
                 }
 
                 Section {
-                    Toggle("Verbose Log (Logs to Console.app)", isOn: settings.$verboseLog)
+                    Toggle(
+                        "Verbose Log (writes prompts including your source code to Console.app)",
+                        isOn: settings.$verboseLog
+                    )
                 }
             }
             .formStyle(.grouped)
diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift
index 0d94e711..cc830a3e 100644
--- a/ExtensionService/AppDelegate.swift
+++ b/ExtensionService/AppDelegate.swift
@@ -15,6 +15,7 @@ import XPCShared
 
 let bundleIdentifierBase = Bundle.main
     .object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String
+let teamIDPrefix = Bundle.main.object(forInfoDictionaryKey: "TEAM_ID_PREFIX") as? String
 let serviceIdentifier = bundleIdentifierBase + ".ExtensionService"
 
 @main
diff --git a/ExtensionService/ServiceDelegate.swift b/ExtensionService/ServiceDelegate.swift
index 6280582f..bfe82dca 100644
--- a/ExtensionService/ServiceDelegate.swift
+++ b/ExtensionService/ServiceDelegate.swift
@@ -1,4 +1,5 @@
 import Foundation
+import Logger
 import Service
 import XPCShared
 
@@ -7,6 +8,22 @@ class ServiceDelegate: NSObject, NSXPCListenerDelegate {
         _: NSXPCListener,
         shouldAcceptNewConnection newConnection: NSXPCConnection
     ) -> Bool {
+        guard let teamID = XPCPeerRequirement.teamID(fromPrefix: teamIDPrefix) else {
+            Logger.service.error(
+                "Rejected XPC connection from pid \(newConnection.processIdentifier): Team ID is unavailable."
+            )
+            return false
+        }
+        let requirement = XPCPeerRequirement.codeSigningRequirement(teamID: teamID)
+        do {
+            try XPCPeerRequirement.setCodeSigningRequirement(on: newConnection, teamID: teamID)
+        } catch {
+            Logger.service.error(
+                "Rejected XPC connection from pid \(newConnection.processIdentifier): invalid code signing requirement (\(requirement))."
+            )
+            return false
+        }
+
         newConnection.exportedInterface = NSXPCInterface(
             with: XPCServiceProtocol.self
         )
diff --git a/Tool/Package.swift b/Tool/Package.swift
index e369afe3..1b5cb437 100644
--- a/Tool/Package.swift
+++ b/Tool/Package.swift
@@ -95,7 +95,10 @@ let package = Package(
     targets: [
         // MARK: - Helpers
 
-        .target(name: "XPCShared", dependencies: ["SuggestionBasic", "Logger"]),
+        .target(
+            name: "XPCShared",
+            dependencies: ["SuggestionBasic", "Logger", "ObjectiveCExceptionHandling"]
+        ),
 
         .target(name: "Configs"),
 
diff --git a/Tool/Sources/XPCShared/XPCPeerRequirement.swift b/Tool/Sources/XPCShared/XPCPeerRequirement.swift
new file mode 100644
index 00000000..4ec26874
--- /dev/null
+++ b/Tool/Sources/XPCShared/XPCPeerRequirement.swift
@@ -0,0 +1,29 @@
+import Foundation
+import ObjectiveCExceptionHandling
+
+public enum XPCPeerRequirement {
+    /// anchor apple generic and certificate leaf[subject.OU] = ""
+    public static func codeSigningRequirement(teamID: String) -> String {
+        #"anchor apple generic and certificate leaf[subject.OU] = "\#(teamID)""#
+    }
+
+    /// `TEAM_ID_PREFIX` is `$(TeamIdentifierPrefix)` (e.g. `94G4SCKS9Z.`).
+    public static func teamID(fromPrefix prefix: String?) -> String? {
+        guard let prefix else { return nil }
+        let teamID = prefix.hasSuffix(".") ? String(prefix.dropLast()) : prefix
+        guard !teamID.isEmpty,
+              teamID.unicodeScalars.allSatisfy({ CharacterSet.alphanumerics.contains($0) })
+        else { return nil }
+        return teamID
+    }
+
+    /// Malformed requirement strings raise `NSException` rather than a Swift `Error`.
+    public static func setCodeSigningRequirement(
+        on connection: NSXPCConnection,
+        teamID: String
+    ) throws {
+        try ObjcExceptionHandler.catchException {
+            connection.setCodeSigningRequirement(codeSigningRequirement(teamID: teamID))
+        }
+    }
+}

From 1441a73ef254273aea8e4cd55f8abac7dbb0285a Mon Sep 17 00:00:00 2001
From: Skyler Liu <9743878+JayDaniel@users.noreply.github.com>
Date: Mon, 7 Sep 2026 21:36:46 -0700
Subject: [PATCH 14/16] Fill test gaps and refresh the docs

- README: list Custom Model under the features, require macOS 13, and
  document the Service > Chat Models > Custom Model > Feature >
  Suggestion setup, the gateway headers that are not forwarded
  upstream, and the MIT provenance
- DEVELOPMENT: running Copilot for Xcode Debug in the fork now starts
  the host, the bridge and ExtensionService. The launch agent owns
  CommunicationBridge, so neither that scheme nor ExtensionService
  should be run directly; keep one dev instance at a time; the .appex
  still follows Apple's EditorExtension flow; debug by attaching to
  the process
- Document Local.xcconfig under Building: copy DEVELOPMENT_TEAM from
  the example, it is gitignored
- Close the remaining gaps: ChatAPIOptions.current for the default,
  an invalid raw value and an empty key; getModel and getStrategy,
  a dangling id included; parseCompletionsStreamLine; and
  response.incomplete

Optionally run the tests on pull_request under macos-15 with Xcode 26
and CODE_SIGNING_ALLOWED=NO. The test step stays continue-on-error
while Xcode 26 is unavailable.

The full run passes with 272 cases: 63 in CustomSuggestionServiceTests,
5 in HostAppTests and 2 in LaunchAgentManagerTests.
---
 .github/workflows/test.yml                    |  36 ++++
 DEVELOPMENT.md                                |  17 +-
 README.md                                     |  13 ++
 .../ChatAPIOptionsAndSemanticsTests.swift     |  52 ++++++
 .../ChatAPIStreamParsingTests.swift           |  33 ++++
 ...mSuggestionCoordinatorSelectionTests.swift | 164 ++++++++++++++++++
 6 files changed, 311 insertions(+), 4 deletions(-)
 create mode 100644 .github/workflows/test.yml
 create mode 100644 Tool/Tests/CustomSuggestionServiceTests/CustomSuggestionCoordinatorSelectionTests.swift

diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 00000000..8a3f650e
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,36 @@
+name: Test
+
+on:
+  pull_request:
+
+jobs:
+  test:
+    name: Copilot for Xcode Debug
+    runs-on: macos-15
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Select Xcode 26
+        id: xcode
+        run: |
+          shopt -s nullglob
+          apps=(/Applications/Xcode_26*.app)
+          if [ ${#apps[@]} -eq 0 ]; then
+            echo "has_xcode26=false" >> "$GITHUB_OUTPUT"
+            echo "Xcode 26 not found; using runner default."
+          else
+            IFS=$'\n' sorted=($(printf '%s\n' "${apps[@]}" | sort -V))
+            app="${sorted[${#sorted[@]}-1]}"
+            sudo xcode-select -s "$app/Contents/Developer"
+            echo "has_xcode26=true" >> "$GITHUB_OUTPUT"
+            echo "Selected $app"
+          fi
+          xcodebuild -version
+
+      - name: Test
+        continue-on-error: ${{ steps.xcode.outputs.has_xcode26 != 'true' }}
+        run: |
+          xcodebuild -workspace "Copilot for Xcode.xcworkspace" -scheme "Copilot for Xcode Debug" \
+            -configuration Debug -destination 'platform=macOS' -skipMacroValidation \
+            CODE_SIGNING_ALLOWED=NO \
+            test -parallel-testing-enabled NO
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index 5b663fbe..2c572d0f 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -27,14 +27,23 @@ Most of the logics are implemented inside the package `Core` and `Tool`.
 
 ## Building and Archiving the App
 
-1. Update the xcconfig files, bridgeLaunchAgent.plist, and Tool/Configs/Configurations.swift.
-2. Build or archive the Copilot for Xcode target.
+1. Copy `Local.xcconfig.example` to `Local.xcconfig` and set `DEVELOPMENT_TEAM` to your Apple Developer Team ID. `Local.xcconfig` is gitignored; `Config.xcconfig` and `Config.debug.xcconfig` include it with `#include?`.
+2. Update the xcconfig files, `bridgeLaunchAgent.plist`, and `Tool/Configs/Configurations.swift` if you are changing identifiers.
+3. Build or archive the Copilot for Xcode target.
 
 ## Testing Source Editor Extension and Service
 
-Just run both the `ExtensionService`, `CommunicationBridge` and the `EditorExtension` Target. Read [Testing Your Source Editor Extension](https://developer.apple.com/documentation/xcodekit/testing_your_source_editor_extension) for more details.
+On this fork, run the `Copilot for Xcode Debug` scheme. That is enough for the host, CommunicationBridge, and ExtensionService: the host registers a launch agent at `~/Library/LaunchAgents/dev.com.intii.CopilotForXcode.CommunicationBridge.plist` and loads it with `launchctl bootstrap`. CommunicationBridge is owned by that launch agent — do **not** Run the `CommunicationBridge` or `ExtensionService` schemes. A second ExtensionService from another bundle path is not reused. Keep only one Dev instance running at a time.
 
-If you are not testing the source editor extension, it's recommended to archive and install a debug version of the Copilot for Xcode and test with the bundled source editor extension.
+The `.appex` still follows Apple's editor-extension workflow. Run the `EditorExtension` scheme when you need to debug the source editor extension itself. See [Testing Your Source Editor Extension](https://developer.apple.com/documentation/xcodekit/testing_your_source_editor_extension).
+
+To uninstall the agent, use **General › Remove Launch Agent** in the host app, or:
+
+```bash
+launchctl bootout gui/$(id -u)/dev.com.intii.CopilotForXcode.CommunicationBridge
+```
+
+To debug ExtensionService, use **Debug › Attach to Process…** on the running `CopilotForXcodeExtensionService` process.
 
 ## SwiftUI Previews
 
diff --git a/README.md b/README.md
index c4066a45..eb0e194d 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,7 @@ Copilot for Xcode is an Xcode Source Editor Extension that provides GitHub Copil
 - Chat
 - Modification
 - Custom Commands to extend Chat and Modification.
+- Custom Model (built-in; OpenAI / OpenAI compatible / Responses API / Claude / Gemini / Ollama / Completion / FIM / Tabby)
 
 ## Table of Contents
 
@@ -32,6 +33,7 @@ Copilot for Xcode is an Xcode Source Editor Extension that provides GitHub Copil
     - [Setting Up Suggestion Feature](#setting-up-suggestion-feature)
       - [Setting Up GitHub Copilot](#setting-up-github-copilot)
       - [Setting Up Codeium](#setting-up-codeium)
+      - [Setting Up Custom Model](#setting-up-custom-model)
     - [Setting Up Chat Feature](#setting-up-chat-feature)
     - [Managing `CopilotForXcodeExtensionService.app`](#managing-copilotforxcodeextensionserviceapp)
   - [Update](#update)
@@ -54,6 +56,7 @@ For more information, check the [Wiki Page](https://copilotforxcode.intii.com/wi
 
 ## Prerequisites
 
+- macOS 13.
 - Public network connection.
 
 For suggestion features:
@@ -183,6 +186,16 @@ The installed language server is located at `~/Library/Application Support/com.i
 
 The installed language server is located at `~/Library/Application Support/com.intii.CopilotForXcode/Codeium/executable/`.
 
+#### Setting Up Custom Model
+
+1. In the host app, go to **Service › Chat Models** and create or update a model.
+2. Go to **Service › Custom Model**, pick that model (or Completion / FIM / Tabby), and set API options for the selected chat model.
+3. Go to **Feature › Suggestion** and set the suggestion provider to **Custom Model**.
+
+Headers sent to the gateway are only meaningful to the gateway itself and are not forwarded upstream.
+
+Custom Model provider merged from [intitni/CustomSuggestionServiceForCopilotForXcode](https://github.com/intitni/CustomSuggestionServiceForCopilotForXcode) (MIT).
+
 ### Setting Up Chat Feature
 
 1. In the host app, navigate to "Service - Chat Model".
diff --git a/Tool/Tests/CustomSuggestionServiceTests/ChatAPIOptionsAndSemanticsTests.swift b/Tool/Tests/CustomSuggestionServiceTests/ChatAPIOptionsAndSemanticsTests.swift
index b6f2e177..0c6c3387 100644
--- a/Tool/Tests/CustomSuggestionServiceTests/ChatAPIOptionsAndSemanticsTests.swift
+++ b/Tool/Tests/CustomSuggestionServiceTests/ChatAPIOptionsAndSemanticsTests.swift
@@ -336,6 +336,58 @@ final class ChatAPIOptionsAndSemanticsTests: XCTestCase {
         XCTAssertNil(json["stop"])
     }
 
+    func test_chatAPIOptions_current_defaults_invalid_raw_and_empty_key_filter() async {
+        let (defaults, suite) = isolatedDefaults()
+        defer { defaults.removePersistentDomain(forName: suite) }
+
+        let model = ChatModel(
+            id: "model-defaults",
+            name: "Test",
+            format: .openAI,
+            info: .init(
+                modelName: "gpt-5.5",
+                customHeaderInfo: .init(headers: [
+                    .init(key: "", value: "skip-me"),
+                    .init(key: "X-Ok", value: "yes"),
+                ])
+            )
+        )
+
+        let options = await ChatAPIOptions.current(
+            for: model,
+            apiKey: "sk",
+            defaults: defaults
+        )
+        XCTAssertEqual(options.api, .chatCompletions)
+        XCTAssertNil(options.reasoningEffort)
+        XCTAssertEqual(options.reasoningTokenBudget, 1500)
+        XCTAssertEqual(options.extraHeaders.map(\.name), ["X-Ok"])
+        XCTAssertEqual(options.extraHeaders.first?.value, "yes")
+
+        defaults.set(
+            "not-json",
+            forKey: UserDefaultPreferenceKeys().customSuggestionChatModelAPIOptions.key
+        )
+        let invalidStored = await ChatAPIOptions.current(
+            for: model,
+            apiKey: "sk",
+            defaults: defaults
+        )
+        XCTAssertEqual(invalidStored.api, .chatCompletions)
+        XCTAssertNil(invalidStored.reasoningEffort)
+        XCTAssertEqual(invalidStored.reasoningTokenBudget, 1500)
+
+        let (legacyDefaults, legacySuite) = isolatedDefaults()
+        defer { legacyDefaults.removePersistentDomain(forName: legacySuite) }
+        legacyDefaults.set("not-an-api", forKey: ChatModelAPIOptions.legacyOpenAIAPIKey)
+        legacyDefaults.set("not-an-effort", forKey: ChatModelAPIOptions.legacyReasoningEffortKey)
+        let migrated = ChatModelAPIOptions.stored(for: "model-defaults", defaults: legacyDefaults)
+        XCTAssertEqual(migrated.api, .chatCompletions)
+        XCTAssertNil(migrated.reasoningEffort)
+        XCTAssertNil(OpenAIChatAPI(rawValue: "not-an-api"))
+        XCTAssertNil(ReasoningEffort(rawValue: "not-an-effort"))
+    }
+
     func test_customBodyInfo_joinJSON_merge() throws {
         let original = Data(#"{"model":"a","stream":true}"#.utf8)
         let merged = CompletionJSON.mergeCustomBody(
diff --git a/Tool/Tests/CustomSuggestionServiceTests/ChatAPIStreamParsingTests.swift b/Tool/Tests/CustomSuggestionServiceTests/ChatAPIStreamParsingTests.swift
index e1af8a97..e3ecd114 100644
--- a/Tool/Tests/CustomSuggestionServiceTests/ChatAPIStreamParsingTests.swift
+++ b/Tool/Tests/CustomSuggestionServiceTests/ChatAPIStreamParsingTests.swift
@@ -38,6 +38,16 @@ class ChatAPIStreamParsingTests: XCTestCase {
         XCTAssertTrue(try OpenAIResponsesService.parseStreamLine("data: [DONE]").done)
     }
 
+    func test_responses_incomplete_finishes_stream() throws {
+        let truncated = #"data: {"type":"response.incomplete","response":{"id":"resp_truncated","object":"response","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"}}}"#
+        let result = try OpenAIResponsesService.parseStreamLine(truncated)
+        XCTAssertNil(result.chunk?.text)
+        XCTAssertTrue(result.done)
+
+        let other = #"data: {"type":"response.incomplete","response":{"id":"resp_other","object":"response","status":"incomplete","incomplete_details":{"reason":"content_filter"}}}"#
+        XCTAssertTrue(try OpenAIResponsesService.parseStreamLine(other).done)
+    }
+
     func test_responses_error_events_throw() {
         XCTAssertThrowsError(try OpenAIResponsesService.parseStreamLine(
             #"data: {"type":"error","code":"rate_limit","message":"slow down"}"#
@@ -120,6 +130,29 @@ class ChatAPIStreamParsingTests: XCTestCase {
         XCTAssertEqual(options.extraHeaders.first?.value, "Bearer sk-1 for gpt-5.5")
     }
 
+    func test_parseCompletionsStreamLine_delta_done_and_error() throws {
+        let delta = #"data: {"id":"cmpl-1","object":"text_completion","choices":[{"text":"foo","index":0,"finish_reason":null}]}"#
+        let result = try OpenAIService.parseCompletionsStreamLine(delta)
+        XCTAssertEqual(result.chunk?.choices?.first?.text, "foo")
+        XCTAssertFalse(result.done)
+
+        XCTAssertTrue(try OpenAIService.parseCompletionsStreamLine("data: [DONE]").done)
+        XCTAssertFalse(try OpenAIService.parseCompletionsStreamLine("").done)
+        XCTAssertFalse(try OpenAIService.parseCompletionsStreamLine(": keepalive").done)
+
+        let finished = #"data: {"choices":[{"text":"","index":0,"finish_reason":"stop"}]}"#
+        XCTAssertTrue(try OpenAIService.parseCompletionsStreamLine(finished).done)
+
+        XCTAssertThrowsError(try OpenAIService.parseCompletionsStreamLine(
+            #"data: {"error":{"message":"boom"}}"#
+        )) { error in
+            guard case OpenAIService.Error.apiError(let message) = error else {
+                return XCTFail("expected apiError, got \(error)")
+            }
+            XCTAssertEqual(message, "boom")
+        }
+    }
+
     func test_chatCompletions_error_line_throws_apiError() {
         XCTAssertThrowsError(try OpenAIService.parseChatCompletionsStreamLine(
             #"data: {"error":{"message":"boom"}}"#
diff --git a/Tool/Tests/CustomSuggestionServiceTests/CustomSuggestionCoordinatorSelectionTests.swift b/Tool/Tests/CustomSuggestionServiceTests/CustomSuggestionCoordinatorSelectionTests.swift
new file mode 100644
index 00000000..94bc21fc
--- /dev/null
+++ b/Tool/Tests/CustomSuggestionServiceTests/CustomSuggestionCoordinatorSelectionTests.swift
@@ -0,0 +1,164 @@
+import AIModel
+import CopilotForXcodeKit
+import Foundation
+import Preferences
+import XCTest
+
+@testable import CustomSuggestionService
+
+final class CustomSuggestionCoordinatorSelectionTests: XCTestCase {
+    func test_getModel_matching_chat_model() async throws {
+        let coordinator = CustomSuggestionCoordinator()
+        let chat = ChatModel(
+            id: "chat-1",
+            name: "GPT",
+            format: .openAI,
+            info: .init(modelName: "gpt-5.5")
+        )
+        let model = try await coordinator.getModel(.init(
+            modelId: "chat-1",
+            chatModels: [chat],
+            strategyId: ""
+        ))
+        guard case let .chatModel(found) = model else {
+            return XCTFail("expected chat model")
+        }
+        XCTAssertEqual(found.id, "chat-1")
+    }
+
+    func test_getModel_dangling_and_empty_id_throw() async {
+        let coordinator = CustomSuggestionCoordinator()
+        await assertModelMissing(
+            coordinator,
+            snapshot: .init(modelId: "dangling", chatModels: [], strategyId: ""),
+            description: "Selected suggestion model \"dangling\" no longer exists. Pick one in Service › Custom Model."
+        )
+        await assertModelMissing(
+            coordinator,
+            snapshot: .init(modelId: "chatModel", chatModels: [], strategyId: ""),
+            description: "Selected suggestion model \"chatModel\" no longer exists. Pick one in Service › Custom Model."
+        )
+        await assertModelMissing(
+            coordinator,
+            snapshot: .init(modelId: "", chatModels: [], strategyId: ""),
+            description: "No suggestion model is selected. Pick one in Service › Custom Model."
+        )
+    }
+
+    func test_getModel_custom_model_types() async throws {
+        let coordinator = CustomSuggestionCoordinator()
+        let completion = try await coordinator.getModel(.init(
+            modelId: CustomModelType.completionModel.rawValue,
+            chatModels: [],
+            strategyId: ""
+        ))
+        guard case .completionModel = completion else {
+            return XCTFail("expected completion model")
+        }
+
+        let tabby = try await coordinator.getModel(.init(
+            modelId: CustomModelType.tabby.rawValue,
+            chatModels: [],
+            strategyId: ""
+        ))
+        guard case .tabbyModel = tabby else {
+            return XCTFail("expected tabby model")
+        }
+
+        let fim = try await coordinator.getModel(.init(
+            modelId: CustomModelType.fimModel.rawValue,
+            chatModels: [],
+            strategyId: ""
+        ))
+        guard case .fimModel = fim else {
+            return XCTFail("expected FIM model")
+        }
+    }
+
+    func test_getStrategy_routes_by_model_and_option() async {
+        let coordinator = CustomSuggestionCoordinator()
+        let request = suggestionRequest()
+
+        let tabby = await coordinator.getStrategy(
+            sourceRequest: request,
+            prefix: ["let x = "],
+            suffix: [],
+            snapshot: .init(
+                modelId: CustomModelType.tabby.rawValue,
+                chatModels: [],
+                strategyId: RequestStrategyOption.naive.rawValue
+            )
+        )
+        XCTAssertTrue(tabby is TabbyRequestStrategy)
+
+        let fim = await coordinator.getStrategy(
+            sourceRequest: request,
+            prefix: ["let x = "],
+            suffix: [],
+            snapshot: .init(
+                modelId: CustomModelType.fimModel.rawValue,
+                chatModels: [],
+                strategyId: ""
+            )
+        )
+        XCTAssertTrue(fim is FIMEndpointRequestStrategy)
+
+        let defaultStrategy = await coordinator.getStrategy(
+            sourceRequest: request,
+            prefix: ["let x = "],
+            suffix: [],
+            snapshot: .init(modelId: "chat-1", chatModels: [], strategyId: "")
+        )
+        XCTAssertTrue(defaultStrategy is DefaultRequestStrategy)
+
+        let naive = await coordinator.getStrategy(
+            sourceRequest: request,
+            prefix: ["let x = "],
+            suffix: [],
+            snapshot: .init(
+                modelId: "chat-1",
+                chatModels: [],
+                strategyId: RequestStrategyOption.naive.rawValue
+            )
+        )
+        XCTAssertTrue(naive is NaiveRequestStrategy)
+
+        let unknown = await coordinator.getStrategy(
+            sourceRequest: request,
+            prefix: ["let x = "],
+            suffix: [],
+            snapshot: .init(modelId: "chat-1", chatModels: [], strategyId: "not-a-strategy")
+        )
+        XCTAssertTrue(unknown is DefaultRequestStrategy)
+    }
+}
+
+private func assertModelMissing(
+    _ coordinator: CustomSuggestionCoordinator,
+    snapshot: CustomSuggestionCoordinator.PreferenceSnapshot,
+    description: String
+) async {
+    do {
+        _ = try await coordinator.getModel(snapshot)
+        XCTFail("expected SelectionError")
+    } catch let error as CustomSuggestionCoordinator.SelectionError {
+        XCTAssertEqual(error.localizedDescription, description)
+    } catch {
+        XCTFail("unexpected \(error)")
+    }
+}
+
+private func suggestionRequest() -> SuggestionRequest {
+    SuggestionRequest(
+        fileURL: URL(fileURLWithPath: "/tmp/a.swift"),
+        relativePath: "a.swift",
+        language: CodeLanguage(rawValue: "swift") ?? .plaintext,
+        content: "let x = ",
+        originalContent: "let x = ",
+        cursorPosition: .init(line: 0, character: 8),
+        tabSize: 4,
+        indentSize: 4,
+        usesTabsForIndentation: false,
+        relevantCodeSnippets: []
+    )
+}

From 6e8b6104ddb509ef6e51eb7811f39eac7ac5318f Mon Sep 17 00:00:00 2001
From: Skyler Liu <9743878+JayDaniel@users.noreply.github.com>
Date: Tue, 8 Sep 2026 02:52:12 -0700
Subject: [PATCH 15/16] Make custom model suggestions usable

Six changes land together, all of them about whether custom model
completion is pleasant to use in practice.

- Accept on Tab the way Cursor does: if a suggestion is showing, Tab
  takes it. The upstream heuristic of letting Tab through when it
  would not invalidate the suggestion turned one Tab into three
  whenever a continuation began with whitespace. Deciding this no
  longer reads the editor content either.
- Strip duplicated indentation in post-processing: when the prefix
  before the cursor ends in whitespace, drop the leading spaces and
  tabs from the continuation.
- Stream the suggestion as it arrives. Add
  StreamingSuggestionServiceProvider and
  StreamingSuggestionServiceType, turn CodeCompletionService into a
  type-erasing factory offering streamCompletion, have the coordinator
  emit a partial result every 50ms, and refresh the widget on each
  one. Accepting, rejecting or invalidating a suggestion stops and
  cancels the request at once. The log gains first token, partial
  count, output tokens and the endpoint actually called.
- Retrieve context. relevantCodeSnippets had always been an empty
  array, which is why completions felt unrelated to the project.
  NeighboringSnippetRetriever scores windows of the other open files
  by Jaccard similarity, the way GitHub Copilot uses neighbouring
  tabs, with no index, no embeddings and no extra round trip.
  RecentEditDiff records the last few edits and renders them as a
  fully commented block, the signal Cursor Tab leans on; commenting
  it keeps the syntax intact inside a FIM prefix. Candidates come
  from the filespaces, topped up from the persisted open file list
  when the service has just restarted.
- Default the OpenAI endpoint to /v1/responses. The old global key
  joins the migration whenever it holds a value, so a configuration
  that explicitly chose chat completions is not overwritten by the
  new default.
- Stop reporting Plus limits in an unlicensed build: the model and
  custom command counts are no longer capped. Features implemented in
  a closed source module stay guarded by canImport at the call site,
  the browser tab among them.

Tests: 21 new cases for neighbouring tab retrieval and recent edits,
plus streaming and post-processing cases, 300 passing in total. Four
WebSearchServiceTests cases need the public network and pass on their
own.

Claude-Session: https://claude.ai/code/session_01JWdMNzGfXMq5ptxmB4Vmgs
---
 .../CustomModelSuggestionView.swift           |   2 +-
 .../TabToAcceptSuggestion.swift               |  48 +--
 .../PlusFeatureFlag/PlusFeatureFlag.swift     |   9 +-
 .../PseudoCommandHandler.swift                |  11 +-
 .../SuggestionService+Streaming.swift         |  67 ++++
 .../TabToAcceptSuggestionTests.swift          | 144 +------
 .../StreamingSuggestionServiceType.swift      |  28 ++
 .../API/OpenAIResponsesService.swift          |  16 +
 .../ChatAPISupport.swift                      |  15 +
 .../CodeCompletionLogger.swift                |  32 ++
 .../CodeCompletionService.swift               | 354 +++++++++++-------
 .../CustomModelSuggestionService.swift        |  40 +-
 .../CustomSuggestionCoordinator.swift         | 258 ++++++++-----
 ...tRawSuggestionPostProcessingStrategy.swift |  11 +
 .../CustomSuggestionChatModelAPIOptions.swift |  11 +-
 .../NeighboringSnippetRetriever.swift         | 183 +++++++++
 .../RecentEditSnippet.swift                   | 137 +++++++
 .../StreamingSuggestionServiceProvider.swift  |  57 +++
 .../SuggestionContextCollector.swift          | 169 +++++++++
 .../Workspace+StreamingSuggestion.swift       |  99 +++++
 .../Workspace+SuggestionService.swift         |   8 +-
 .../ChatAPIOptionsAndSemanticsTests.swift     |   8 +-
 ...icatedIndentationPostProcessingTests.swift |  43 +++
 .../StreamingCompletionTests.swift            | 148 ++++++++
 .../NeighboringSnippetRetrieverTests.swift    | 154 ++++++++
 .../RecentEditSnippetTests.swift              | 149 ++++++++
 26 files changed, 1778 insertions(+), 423 deletions(-)
 create mode 100644 Core/Sources/SuggestionService/SuggestionService+Streaming.swift
 create mode 100644 Tool/Sources/BuiltinExtension/StreamingSuggestionServiceType.swift
 create mode 100644 Tool/Sources/SuggestionProvider/NeighboringSnippetRetriever.swift
 create mode 100644 Tool/Sources/SuggestionProvider/RecentEditSnippet.swift
 create mode 100644 Tool/Sources/SuggestionProvider/StreamingSuggestionServiceProvider.swift
 create mode 100644 Tool/Sources/WorkspaceSuggestionService/SuggestionContextCollector.swift
 create mode 100644 Tool/Sources/WorkspaceSuggestionService/Workspace+StreamingSuggestion.swift
 create mode 100644 Tool/Tests/CustomSuggestionServiceTests/DuplicatedIndentationPostProcessingTests.swift
 create mode 100644 Tool/Tests/CustomSuggestionServiceTests/StreamingCompletionTests.swift
 create mode 100644 Tool/Tests/SuggestionProviderTests/NeighboringSnippetRetrieverTests.swift
 create mode 100644 Tool/Tests/SuggestionProviderTests/RecentEditSnippetTests.swift

diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift
index 2961eb9f..b3166af7 100644
--- a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift
+++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CustomModelSuggestionView.swift
@@ -244,7 +244,7 @@ struct ChatAPIOptionsPicker: View {
                 selection: Binding(
                     get: { currentOptions.api.rawValue },
                     set: { newValue in
-                        update { $0.api = OpenAIChatAPI(rawValue: newValue) ?? .chatCompletions }
+                        update { $0.api = OpenAIChatAPI(rawValue: newValue) ?? .responses }
                     }
                 ),
                 label: Text("OpenAI Endpoint")
diff --git a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift
index 9c81038f..4e9c5039 100644
--- a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift
+++ b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift
@@ -176,7 +176,7 @@ final class TabToAcceptSuggestion {
             Logger.service.info("TabToAcceptSuggestion: Xcode not found")
             return .unchanged
         }
-        guard let editor = ThreadSafeAccessToXcodeInspector.shared.focusedEditor
+        guard ThreadSafeAccessToXcodeInspector.shared.focusedEditor != nil
         else {
             Logger.service.info("TabToAcceptSuggestion: No editor found")
             return .unchanged
@@ -194,12 +194,7 @@ final class TabToAcceptSuggestion {
             return .unchanged
         }
 
-        let editorContent = editor.getContent()
-
         let shouldAcceptSuggestion = Self.checkIfAcceptSuggestion(
-            lines: editorContent.lines,
-            cursorPosition: editorContent.cursorPosition,
-            codeMetadata: filespace.codeMetadata,
             presentingSuggestionText: presentingSuggestion.text
         )
 
@@ -243,39 +238,14 @@ final class TabToAcceptSuggestion {
 }
 
 extension TabToAcceptSuggestion {
-    static func checkIfAcceptSuggestion(
-        lines: [String],
-        cursorPosition: CursorPosition,
-        codeMetadata: FilespaceCodeMetadata,
-        presentingSuggestionText: String
-    ) -> Bool {
-        let line = cursorPosition.line
-        guard line >= 0, line < lines.endIndex else {
-            return true
-        }
-        let col = cursorPosition.character
-        let prefixEndIndex = lines[line].utf16.index(
-            lines[line].utf16.startIndex,
-            offsetBy: col,
-            limitedBy: lines[line].utf16.endIndex
-        ) ?? lines[line].utf16.endIndex
-        let prefix = String(lines[line][.. Bool {
+        !presentingSuggestionText.isEmpty
     }
 }
 
diff --git a/Core/Sources/PlusFeatureFlag/PlusFeatureFlag.swift b/Core/Sources/PlusFeatureFlag/PlusFeatureFlag.swift
index ce45eaf8..a0eadc4b 100644
--- a/Core/Sources/PlusFeatureFlag/PlusFeatureFlag.swift
+++ b/Core/Sources/PlusFeatureFlag/PlusFeatureFlag.swift
@@ -23,6 +23,8 @@ public func withFeatureEnabled(
 ) rethrows {
     #if canImport(LicenseManagement)
     try LicenseManagement.withFeatureEnabled(flag, then: then)
+    #else
+    try then()
     #endif
 }
 
@@ -32,6 +34,8 @@ public func withFeatureEnabled(
 ) async rethrows {
     #if canImport(LicenseManagement)
     try await LicenseManagement.withFeatureEnabled(flag, then: then)
+    #else
+    try await then()
     #endif
 }
 
@@ -39,7 +43,10 @@ public func isFeatureAvailable(_ flag: KeyPath AsyncThrowingStream<[SuggestionBasic.CodeSuggestion], Error> {
+        guard let streamingProvider = suggestionProvider as? StreamingSuggestionServiceProvider
+        else {
+            return SuggestionStreams.single {
+                try await self.getSuggestions(request, workspaceInfo: workspaceInfo)
+            }
+        }
+
+        let configuration = await configuration
+        let middlewares = middlewares
+        let upstream = await streamingProvider.streamSuggestions(
+            request,
+            workspaceInfo: workspaceInfo
+        )
+        return SuggestionStreams.forward(upstream, mapError: Self.wrapAsSilentIfNeeded) { partial in
+            try await Self.applyMiddlewares(
+                middlewares,
+                to: partial,
+                request: request,
+                configuration: configuration
+            )
+        }
+    }
+
+    /// Same rule as the blocking path: unknown errors are shown nowhere but the log.
+    static func wrapAsSilentIfNeeded(_ error: Error) -> Error {
+        if error is CancellationError { return error }
+        if error is SuggestionServiceError { return error }
+        return SuggestionServiceError.silent(error)
+    }
+
+    /// Runs `suggestions` through the middleware chain as if the provider had returned them.
+    static func applyMiddlewares(
+        _ middlewares: [Middleware],
+        to suggestions: [SuggestionBasic.CodeSuggestion],
+        request: SuggestionRequest,
+        configuration: SuggestionProvider.SuggestionServiceConfiguration
+    ) async throws -> [SuggestionBasic.CodeSuggestion] {
+        var next: (SuggestionRequest) async throws -> [SuggestionBasic.CodeSuggestion] = { _ in
+            suggestions
+        }
+        for middleware in middlewares.reversed() {
+            let inner = next
+            next = { request in
+                try await middleware.getSuggestion(
+                    request,
+                    configuration: configuration,
+                    next: inner
+                )
+            }
+        }
+        return try await next(request)
+    }
+}
diff --git a/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift b/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift
index b84e8ac0..c2aaf4e9 100644
--- a/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift
+++ b/Core/Tests/KeyBindingManagerTests/TabToAcceptSuggestionTests.swift
@@ -5,149 +5,33 @@ import XCTest
 @testable import KeyBindingManager
 
 class TabToAcceptSuggestionTests: XCTestCase {
-    func test_should_accept_if_line_invalid() {
+    /// Cursor-style: Tab accepts whatever is presented, even when typing a tab would not
+    /// invalidate the suggestion (upstream used to pass those Tabs through to Xcode).
+    func test_accepts_when_the_suggestion_begins_with_indentation() {
         XCTAssertTrue(
             TabToAcceptSuggestion.checkIfAcceptSuggestion(
-                lines: """
-                struct Cat {
-                    var name: String
-                    var age: Int
-                }
-                """.breakLines(),
-                cursorPosition: .init(line: 4, character: 4),
-                codeMetadata: .init(),
-                presentingSuggestionText: "Hello"
-            )
-        )
-        
-        XCTAssertTrue(
-            TabToAcceptSuggestion.checkIfAcceptSuggestion(
-                lines: """
-                struct Cat {
-                    var name: String
-                    var age: Int
-                }
-                """.breakLines(),
-                cursorPosition: .init(line: -1, character: 4),
-                codeMetadata: .init(),
-                presentingSuggestionText: "Hello"
-            )
-        )
-    }
-    
-    func test_should_not_accept_if_tab_does_not_invalidate_the_suggestion() {
-        XCTAssertFalse(
-            TabToAcceptSuggestion.checkIfAcceptSuggestion(
-                lines: """
-                struct Cat {
-                
-                    var age: Int
-                }
-                """.breakLines(),
-                cursorPosition: .init(line: 1, character: 0),
-                codeMetadata: .init(tabSize: 4, indentSize: 4, usesTabsForIndentation: false),
                 presentingSuggestionText: "    var name: String"
             )
         )
-        
-        XCTAssertFalse(
-            TabToAcceptSuggestion.checkIfAcceptSuggestion(
-                lines: """
-                struct 🐱 {
-                
-                    var 🎇: Int
-                }
-                """.breakLines(),
-                cursorPosition: .init(line: 1, character: 0),
-                codeMetadata: .init(tabSize: 4, indentSize: 4, usesTabsForIndentation: false),
-                presentingSuggestionText: "    var 🎇: String"
-            )
-        )
-        
-        XCTAssertFalse(
-            TabToAcceptSuggestion.checkIfAcceptSuggestion(
-                lines: """
-                struct Cat {
-                
-                  var age: Int
-                }
-                """.breakLines(),
-                cursorPosition: .init(line: 1, character: 0),
-                codeMetadata: .init(tabSize: 2, indentSize: 2, usesTabsForIndentation: false),
-                presentingSuggestionText: "  var name: String"
-            )
-        )
-        
-        XCTAssertFalse(
+        XCTAssertTrue(
             TabToAcceptSuggestion.checkIfAcceptSuggestion(
-                lines: """
-                struct Cat {
-                
-                \tvar age: Int
-                }
-                """.breakLines(),
-                cursorPosition: .init(line: 1, character: 0),
-                codeMetadata: .init(tabSize: 4, indentSize: 1, usesTabsForIndentation: true),
                 presentingSuggestionText: "\tvar name: String"
             )
         )
     }
-    
-    func test_should_accept_if_tab_invalidates_the_suggestion() {
-        XCTAssertTrue(
-            TabToAcceptSuggestion.checkIfAcceptSuggestion(
-                lines: """
-                struct Cat {
-                \(" ")
-                    var age: Int
-                }
-                """.breakLines(),
-                cursorPosition: .init(line: 1, character: 1),
-                codeMetadata: .init(tabSize: 4, indentSize: 4, usesTabsForIndentation: false),
-                presentingSuggestionText: "    var name: String"
-            )
-        )
-        
-        XCTAssertTrue(
-            TabToAcceptSuggestion.checkIfAcceptSuggestion(
-                lines: """
-                struct 🐱 {
-                \(" ")
-                    var 🎇: Int
-                }
-                """.breakLines(),
-                cursorPosition: .init(line: 1, character: 1),
-                codeMetadata: .init(tabSize: 4, indentSize: 4, usesTabsForIndentation: false),
-                presentingSuggestionText: "    var 🎇: String"
-            )
-        )
-        
-        XCTAssertTrue(
-            TabToAcceptSuggestion.checkIfAcceptSuggestion(
-                lines: """
-                struct Cat {
-                \(" ")
-                  var age: Int
-                }
-                """.breakLines(),
-                cursorPosition: .init(line: 1, character: 1),
-                codeMetadata: .init(tabSize: 2, indentSize: 2, usesTabsForIndentation: false),
-                presentingSuggestionText: "  var name: String"
-            )
-        )
-        
+
+    /// The reported case: the completion after `/// ` started with eight spaces, so upstream's
+    /// rule needed three Tabs before accepting.
+    func test_accepts_when_the_completion_after_the_line_prefix_starts_with_whitespace() {
         XCTAssertTrue(
             TabToAcceptSuggestion.checkIfAcceptSuggestion(
-                lines: """
-                struct Cat {
-                \t
-                \tvar age: Int
-                }
-                """.breakLines(),
-                cursorPosition: .init(line: 1, character: 1),
-                codeMetadata: .init(tabSize: 4, indentSize: 1, usesTabsForIndentation: true),
-                presentingSuggestionText: "\tvar name: String"
+                presentingSuggestionText: "/// " + String(repeating: " ", count: 8)
+                    + "))\nlet shareItem = UIBarButtonItem("
             )
         )
     }
+
+    func test_does_not_accept_an_empty_suggestion() {
+        XCTAssertFalse(TabToAcceptSuggestion.checkIfAcceptSuggestion(presentingSuggestionText: ""))
+    }
 }
diff --git a/Tool/Sources/BuiltinExtension/StreamingSuggestionServiceType.swift b/Tool/Sources/BuiltinExtension/StreamingSuggestionServiceType.swift
new file mode 100644
index 00000000..6d67fe5f
--- /dev/null
+++ b/Tool/Sources/BuiltinExtension/StreamingSuggestionServiceType.swift
@@ -0,0 +1,28 @@
+import CopilotForXcodeKit
+import Foundation
+import SuggestionBasic
+import SuggestionProvider
+
+/// A builtin suggestion service that can report partial results while it is still generating.
+/// Element semantics are the same as ``StreamingSuggestionServiceProvider``.
+public protocol StreamingSuggestionServiceType: CopilotForXcodeKit.SuggestionServiceType {
+    func streamSuggestions(
+        _ request: CopilotForXcodeKit.SuggestionRequest,
+        workspace: WorkspaceInfo
+    ) async -> AsyncThrowingStream<[CopilotForXcodeKit.CodeSuggestion], Error>
+}
+
+extension BuiltinExtensionSuggestionServiceProvider: StreamingSuggestionServiceProvider {
+    public func streamSuggestions(
+        _ request: SuggestionProvider.SuggestionRequest,
+        workspaceInfo: CopilotForXcodeKit.WorkspaceInfo
+    ) async -> AsyncThrowingStream<[SuggestionBasic.CodeSuggestion], Error> {
+        guard let streaming = service as? StreamingSuggestionServiceType else {
+            return SuggestionStreams.single {
+                try await self.getSuggestions(request, workspaceInfo: workspaceInfo)
+            }
+        }
+        let upstream = await streaming.streamSuggestions(request.converted, workspace: workspaceInfo)
+        return SuggestionStreams.forward(upstream) { $0.map(\.converted) }
+    }
+}
diff --git a/Tool/Sources/CustomSuggestionService/API/OpenAIResponsesService.swift b/Tool/Sources/CustomSuggestionService/API/OpenAIResponsesService.swift
index 63121054..4802a00b 100644
--- a/Tool/Sources/CustomSuggestionService/API/OpenAIResponsesService.swift
+++ b/Tool/Sources/CustomSuggestionService/API/OpenAIResponsesService.swift
@@ -245,6 +245,15 @@ extension OpenAIResponsesService {
         var reason: String?
     }
 
+    struct Usage: Decodable {
+        struct OutputTokensDetails: Decodable {
+            var reasoning_tokens: Int?
+        }
+
+        var output_tokens: Int?
+        var output_tokens_details: OutputTokensDetails?
+    }
+
     struct ResponseObject: Decodable {
         var id: String?
         var object: String?
@@ -252,6 +261,7 @@ extension OpenAIResponsesService {
         var output: [OutputItem]?
         var error: APIError?
         var incomplete_details: IncompleteDetails?
+        var usage: Usage? = nil
 
         var outputText: String {
             (output ?? [])
@@ -299,6 +309,12 @@ extension OpenAIResponsesService {
         case "response.output_text.delta":
             return .init(chunk: .init(text: event.delta), done: false)
         case "response.completed":
+            if let usage = event.response?.usage {
+                CodeCompletionLogger.logger.logUsage(
+                    outputTokens: usage.output_tokens,
+                    reasoningTokens: usage.output_tokens_details?.reasoning_tokens
+                )
+            }
             return .init(chunk: nil, done: true)
         case "response.incomplete":
             if event.response?.incomplete_details?.reason == "max_output_tokens" {
diff --git a/Tool/Sources/CustomSuggestionService/ChatAPISupport.swift b/Tool/Sources/CustomSuggestionService/ChatAPISupport.swift
index a192cbbd..63de295c 100644
--- a/Tool/Sources/CustomSuggestionService/ChatAPISupport.swift
+++ b/Tool/Sources/CustomSuggestionService/ChatAPISupport.swift
@@ -176,6 +176,21 @@ enum CompletionStopWords {
         guard let earliest else { return nil }
         return String(text[..`.
+    static func displayablePrefix(of text: String, stopWords: [String]) -> String {
+        var cut = 0
+        for word in stopWords where word.count > 1 {
+            for length in stride(from: word.count - 1, through: 1, by: -1) where length > cut {
+                if text.hasSuffix(word.prefix(length)) {
+                    cut = length
+                    break
+                }
+            }
+        }
+        return cut == 0 ? text : String(text.dropLast(cut))
+    }
 }
 
 /// Helpers for `text/event-stream` lines.
diff --git a/Tool/Sources/CustomSuggestionService/CodeCompletionLogger.swift b/Tool/Sources/CustomSuggestionService/CodeCompletionLogger.swift
index fb2399c1..b4f09c2b 100644
--- a/Tool/Sources/CustomSuggestionService/CodeCompletionLogger.swift
+++ b/Tool/Sources/CustomSuggestionService/CodeCompletionLogger.swift
@@ -29,6 +29,11 @@ public final class CodeCompletionLogger {
     var model = Model(type: "", format: "", modelName: "", baseURL: "")
     var prompt: [(message: String, role: String)] = []
     var responses: [String] = []
+    var endpoint: String?
+    var firstTokenTime: Date?
+    var partialCount = 0
+    var outputTokens: Int?
+    var reasoningTokens: Int?
     let startTime = Date()
     let id = UUID()
 
@@ -44,6 +49,11 @@ public final class CodeCompletionLogger {
         self.request = request
     }
 
+    /// The URL the request actually goes to, once the chat API choice is resolved.
+    public func logEndpoint(_ url: String) {
+        endpoint = url
+    }
+
     public func logModel(_ chatModel: ChatModel) {
         model = .init(
             type: "Chat Completion",
@@ -88,6 +98,22 @@ public final class CodeCompletionLogger {
         responses.append(response)
     }
 
+    /// Called when the first non-empty token arrives; later calls are ignored.
+    public func logFirstToken() {
+        if firstTokenTime == nil { firstTokenTime = Date() }
+    }
+
+    /// Called every time a partial suggestion is handed to the UI while streaming.
+    public func logPartial() {
+        partialCount += 1
+    }
+
+    /// Token usage reported by the API, when the protocol carries it.
+    public func logUsage(outputTokens: Int?, reasoningTokens: Int?) {
+        self.outputTokens = outputTokens
+        self.reasoningTokens = reasoningTokens
+    }
+
     public func error(_ error: Error) {
         if error is CancellationError { return }
         if let urlError = error as? URLError, urlError.code == .cancelled { return }
@@ -111,6 +137,8 @@ public final class CodeCompletionLogger {
         let now = Date()
         let duration = now.timeIntervalSince(startTime)
         let formattedDuration = String(format: "%.2f", duration)
+        let formattedFirstToken = firstTokenTime
+            .map { String(format: "%.2f", $0.timeIntervalSince(startTime)) } ?? "n/a"
 
         Logger.service.info("""
         [Request] \(id)
@@ -118,7 +146,11 @@ public final class CodeCompletionLogger {
         Format: \(model.format)
         Model Name: \(model.modelName)
         Base URL: \(model.baseURL)
+        Endpoint: \(endpoint ?? "n/a")
         Duration: \(formattedDuration)
+        First Token: \(formattedFirstToken)
+        Partials: \(partialCount)
+        Output Tokens: \(outputTokens.map(String.init) ?? "n/a") (reasoning: \(reasoningTokens.map(String.init) ?? "n/a"))
         ---
         File URL: \(request.fileURL)
         Code Snippets: \(request.relevantCodeSnippets.count) snippets
diff --git a/Tool/Sources/CustomSuggestionService/CodeCompletionService.swift b/Tool/Sources/CustomSuggestionService/CodeCompletionService.swift
index c847db65..cadd462f 100644
--- a/Tool/Sources/CustomSuggestionService/CodeCompletionService.swift
+++ b/Tool/Sources/CustomSuggestionService/CodeCompletionService.swift
@@ -45,14 +45,58 @@ extension CodeCompletionServiceType {
             return result.filter { !$0.isEmpty }
         }
     }
+
+    /// Streams one completion while it is generated.
+    ///
+    /// Every element is the whole text received so far, already cut by stop words and the line
+    /// limit, minus a trailing fragment that could still turn out to be a stop word. The last
+    /// element is exactly what ``getCompletions(_:streamStopStrategy:count:)`` would return.
+    func streamCompletion(
+        _ request: PromptStrategy,
+        streamStopStrategy: StreamStopStrategy
+    ) -> AsyncThrowingStream {
+        AsyncThrowingStream { continuation in
+            let task = Task {
+                do {
+                    let limiter = StreamLineLimiter(strategy: streamStopStrategy)
+                    let stream = try await getCompletion(request)
+                    let result = try await feedCompletion(
+                        from: stream,
+                        stopWords: request.stopWords,
+                        limiter: limiter
+                    ) { partial in
+                        continuation.yield(partial)
+                    }
+                    try Task.checkCancellation()
+                    continuation.yield(result)
+                    continuation.finish()
+                } catch {
+                    continuation.finish(throwing: error)
+                }
+            }
+            continuation.onTermination = { _ in task.cancel() }
+        }
+    }
 }
 
 func collectCompletion(
     from stream: S,
     stopWords: [String],
     limiter: StreamLineLimiter
+) async throws -> String where S.Element == String {
+    try await feedCompletion(from: stream, stopWords: stopWords, limiter: limiter) { _ in }
+}
+
+/// Feeds `stream` into `limiter`, reporting the displayable text after every token, and returns
+/// the final text: cut at the first stop word, or where the limiter decided to stop.
+func feedCompletion(
+    from stream: S,
+    stopWords: [String],
+    limiter: StreamLineLimiter,
+    onPartial: (String) -> Void
 ) async throws -> String where S.Element == String {
     for try await response in stream {
+        if !response.isEmpty { CodeCompletionLogger.logger.logFirstToken() }
         let push = limiter.push(response)
         if let truncated = CompletionStopWords.truncatedPrefix(
             of: limiter.result,
@@ -63,10 +107,43 @@ func collectCompletion(
         if case let .finish(result) = push {
             return result
         }
+        onPartial(CompletionStopWords.displayablePrefix(of: limiter.result, stopWords: stopWords))
     }
     return limiter.result
 }
 
+/// Type-erased ``CodeCompletionServiceType`` so the per-model dispatch can serve both the blocking
+/// and the streaming entry points. Errors and cancellation pass through unchanged.
+struct ErasedCompletionService: CodeCompletionServiceType {
+    typealias CompletionSequence = AsyncThrowingStream
+
+    let makeCompletion: (PromptStrategy) async throws -> CompletionSequence
+
+    init(_ service: Service) {
+        makeCompletion = { request in
+            let sequence = try await service.getCompletion(request)
+            return AsyncThrowingStream { continuation in
+                let task = Task {
+                    do {
+                        for try await element in sequence {
+                            try Task.checkCancellation()
+                            continuation.yield(element)
+                        }
+                        continuation.finish()
+                    } catch {
+                        continuation.finish(throwing: error)
+                    }
+                }
+                continuation.onTermination = { _ in task.cancel() }
+            }
+        }
+    }
+
+    func getCompletion(_ request: PromptStrategy) async throws -> CompletionSequence {
+        try await makeCompletion(request)
+    }
+}
+
 public struct CodeCompletionService {
     public init() {}
 
@@ -84,15 +161,113 @@ public struct CodeCompletionService {
         }
     }
 
+    // MARK: Blocking
+
+    func getCompletions(
+        _ prompt: PromptStrategy,
+        streamStopStrategy: StreamStopStrategy,
+        model: CustomSuggestionCoordinator.Model,
+        count: Int
+    ) async throws -> [String] {
+        let result = try await completionService(for: model, prompt: prompt).getCompletions(
+            prompt,
+            streamStopStrategy: streamStopStrategy,
+            count: count
+        )
+        try Task.checkCancellation()
+        return result
+    }
+
     public func getCompletions(
         _ prompt: PromptStrategy,
         streamStopStrategy: StreamStopStrategy,
         model: TabbyModel,
         count: Int
     ) async throws -> [String] {
+        try await getCompletions(
+            prompt,
+            streamStopStrategy: streamStopStrategy,
+            model: .tabbyModel(model),
+            count: count
+        )
+    }
+
+    public func getCompletions(
+        _ prompt: PromptStrategy,
+        streamStopStrategy: StreamStopStrategy,
+        model: ChatModel,
+        count: Int
+    ) async throws -> [String] {
+        try await getCompletions(
+            prompt,
+            streamStopStrategy: streamStopStrategy,
+            model: .chatModel(model),
+            count: count
+        )
+    }
+
+    public func getCompletions(
+        _ prompt: PromptStrategy,
+        streamStopStrategy: StreamStopStrategy,
+        model: CompletionModel,
+        count: Int
+    ) async throws -> [String] {
+        try await getCompletions(
+            prompt,
+            streamStopStrategy: streamStopStrategy,
+            model: .completionModel(model),
+            count: count
+        )
+    }
+
+    public func getCompletions(
+        _ prompt: PromptStrategy,
+        streamStopStrategy: StreamStopStrategy,
+        model: FIMModel,
+        count: Int
+    ) async throws -> [String] {
+        try await getCompletions(
+            prompt,
+            streamStopStrategy: streamStopStrategy,
+            model: .fimModel(model),
+            count: count
+        )
+    }
+
+    // MARK: Streaming
+
+    /// See ``CodeCompletionServiceType/streamCompletion(_:streamStopStrategy:)``.
+    func streamCompletion(
+        _ prompt: PromptStrategy,
+        streamStopStrategy: StreamStopStrategy,
+        model: CustomSuggestionCoordinator.Model
+    ) async throws -> AsyncThrowingStream {
+        try await completionService(for: model, prompt: prompt)
+            .streamCompletion(prompt, streamStopStrategy: streamStopStrategy)
+    }
+
+    // MARK: Per-model dispatch
+
+    func completionService(
+        for model: CustomSuggestionCoordinator.Model,
+        prompt: PromptStrategy
+    ) async throws -> ErasedCompletionService {
+        switch model {
+        case let .chatModel(model):
+            return try await completionService(for: model, prompt: prompt)
+        case let .completionModel(model):
+            return try completionService(for: model, prompt: prompt)
+        case let .tabbyModel(model):
+            return completionService(for: model, prompt: prompt)
+        case let .fimModel(model):
+            return try completionService(for: model, prompt: prompt)
+        }
+    }
+
+    func completionService(for model: TabbyModel, prompt: PromptStrategy) -> ErasedCompletionService {
         let apiKey = apiKey(from: model)
 
-        let service = TabbyService(url: model.url, authorizationMode: {
+        return .init(TabbyService(url: model.url, authorizationMode: {
             switch model.authorizationMode {
             case .none:
                 return .none
@@ -103,33 +278,26 @@ public struct CodeCompletionService {
             case .customHeaderField:
                 return .customHeaderField(name: model.authorizationHeaderName, value: apiKey)
             }
-        }())
-
-        let result = try await service.getCompletions(
-            prompt,
-            streamStopStrategy: streamStopStrategy,
-            count: count
-        )
-        try Task.checkCancellation()
-        return result
+        }()))
     }
 
-    public func getCompletions(
-        _ prompt: PromptStrategy,
-        streamStopStrategy: StreamStopStrategy,
-        model: ChatModel,
-        count: Int
-    ) async throws -> [String] {
+    func completionService(
+        for model: ChatModel,
+        prompt: PromptStrategy
+    ) async throws -> ErasedCompletionService {
         let apiKey = apiKey(from: model)
 
         let options = await ChatAPIOptions.current(for: model, apiKey: apiKey)
+        CodeCompletionLogger.logger.logEndpoint(model.endpoint)
 
         switch model.format {
         case .openAI, .openAICompatible:
             switch options.api {
             case .responses:
-                let service = OpenAIResponsesService(
-                    url: Self.responsesEndpoint(for: model),
+                let responsesURL = Self.responsesEndpoint(for: model)
+                CodeCompletionLogger.logger.logEndpoint(responsesURL)
+                return .init(OpenAIResponsesService(
+                    url: responsesURL,
                     modelName: model.info.modelName,
                     contextWindow: model.info.maxTokens,
                     maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken),
@@ -144,16 +312,9 @@ public struct CodeCompletionService {
                     requiresBeginWithUserMessage: model.info.openAICompatibleInfo
                         .requiresBeginWithUserMessage,
                     enforceMessageOrder: model.info.openAICompatibleInfo.enforceMessageOrder
-                )
-                let result = try await service.getCompletions(
-                    prompt,
-                    streamStopStrategy: streamStopStrategy,
-                    count: count
-                )
-                try Task.checkCancellation()
-                return result
+                ))
             case .chatCompletions:
-                let service = try OpenAIService(
+                return .init(try OpenAIService(
                     url: model.endpoint,
                     endpoint: .chatCompletion,
                     modelName: model.info.modelName,
@@ -170,17 +331,10 @@ public struct CodeCompletionService {
                     requiresBeginWithUserMessage: model.info.openAICompatibleInfo
                         .requiresBeginWithUserMessage,
                     enforceMessageOrder: model.info.openAICompatibleInfo.enforceMessageOrder
-                )
-                let result = try await service.getCompletions(
-                    prompt,
-                    streamStopStrategy: streamStopStrategy,
-                    count: count
-                )
-                try Task.checkCancellation()
-                return result
+                ))
             }
         case .azureOpenAI:
-            let service = try OpenAIService(
+            return .init(try OpenAIService(
                 url: model.endpoint,
                 endpoint: .chatCompletion,
                 modelName: model.info.modelName,
@@ -192,32 +346,18 @@ public struct CodeCompletionService {
                 reasoningEffort: options.reasoningEffort,
                 reasoningTokenBudget: options.reasoningTokenBudget,
                 extraHeaders: options.extraHeaders
-            )
-            let result = try await service.getCompletions(
-                prompt,
-                streamStopStrategy: streamStopStrategy,
-                count: count
-            )
-            try Task.checkCancellation()
-            return result
+            ))
         case .googleAI:
-            let service = GoogleGeminiService(
+            return .init(GoogleGeminiService(
                 modelName: model.info.modelName,
                 contextWindow: model.info.maxTokens,
                 maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken),
                 apiKey: apiKey,
                 baseURL: model.endpoint,
                 apiVersion: model.info.googleGenerativeAIInfo.apiVersion
-            )
-            let result = try await service.getCompletions(
-                prompt,
-                streamStopStrategy: streamStopStrategy,
-                count: count
-            )
-            try Task.checkCancellation()
-            return result
+            ))
         case .ollama:
-            let service = OllamaService(
+            return .init(OllamaService(
                 url: model.endpoint,
                 endpoint: .chatCompletion,
                 modelName: model.info.modelName,
@@ -227,16 +367,9 @@ public struct CodeCompletionService {
                 keepAlive: model.info.ollamaInfo.keepAlive,
                 format: .none,
                 extraHeaders: options.extraHeaders
-            )
-            let result = try await service.getCompletions(
-                prompt,
-                streamStopStrategy: streamStopStrategy,
-                count: count
-            )
-            try Task.checkCancellation()
-            return result
+            ))
         case .claude:
-            let service = AnthropicService(
+            return .init(AnthropicService(
                 url: model.endpoint,
                 modelName: model.info.modelName,
                 contextWindow: model.info.maxTokens,
@@ -246,30 +379,21 @@ public struct CodeCompletionService {
                 extraHeaders: options.extraHeaders,
                 reasoningEffort: options.reasoningEffort,
                 customJSONBody: model.info.customBodyInfo.jsonBody
-            )
-            let result = try await service.getCompletions(
-                prompt,
-                streamStopStrategy: streamStopStrategy,
-                count: count
-            )
-            try Task.checkCancellation()
-            return result
+            ))
         case .gitHubCopilot:
             throw Error.gitHubCopilotNotSupported
         }
     }
 
-    public func getCompletions(
-        _ prompt: PromptStrategy,
-        streamStopStrategy: StreamStopStrategy,
-        model: CompletionModel,
-        count: Int
-    ) async throws -> [String] {
+    func completionService(
+        for model: CompletionModel,
+        prompt: PromptStrategy
+    ) throws -> ErasedCompletionService {
         let apiKey = apiKey(from: model)
 
         switch model.format {
         case .openAI, .openAICompatible:
-            let service = try OpenAIService(
+            return .init(try OpenAIService(
                 url: model.endpoint,
                 endpoint: .completion,
                 modelName: model.info.modelName,
@@ -277,16 +401,9 @@ public struct CodeCompletionService {
                 maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken),
                 stopWords: prompt.stopWords,
                 apiKey: apiKey
-            )
-            let result = try await service.getCompletions(
-                prompt,
-                streamStopStrategy: streamStopStrategy,
-                count: count
-            )
-            try Task.checkCancellation()
-            return result
+            ))
         case .azureOpenAI:
-            let service = try OpenAIService(
+            return .init(try OpenAIService(
                 url: model.endpoint,
                 endpoint: .completion,
                 modelName: model.info.modelName,
@@ -295,16 +412,9 @@ public struct CodeCompletionService {
                 stopWords: prompt.stopWords,
                 apiKey: apiKey,
                 authentication: .apiKeyHeader
-            )
-            let result = try await service.getCompletions(
-                prompt,
-                streamStopStrategy: streamStopStrategy,
-                count: count
-            )
-            try Task.checkCancellation()
-            return result
+            ))
         case .ollama:
-            let service = OllamaService(
+            return .init(OllamaService(
                 url: model.endpoint,
                 endpoint: .completion,
                 modelName: model.info.modelName,
@@ -313,30 +423,21 @@ public struct CodeCompletionService {
                 stopWords: prompt.stopWords,
                 keepAlive: model.info.ollamaInfo.keepAlive,
                 format: .none
-            )
-            let result = try await service.getCompletions(
-                prompt,
-                streamStopStrategy: streamStopStrategy,
-                count: count
-            )
-            try Task.checkCancellation()
-            return result
+            ))
         case .unknown:
             throw Error.unknownFormat
         }
     }
 
-    public func getCompletions(
-        _ prompt: PromptStrategy,
-        streamStopStrategy: StreamStopStrategy,
-        model: FIMModel,
-        count: Int
-    ) async throws -> [String] {
+    func completionService(
+        for model: FIMModel,
+        prompt: PromptStrategy
+    ) throws -> ErasedCompletionService {
         let apiKey = apiKey(from: model)
 
         switch model.format {
         case .mistral:
-            let service = MistralFIMService(
+            return .init(MistralFIMService(
                 url: URL(string: model.endpoint),
                 model: model.info.modelName,
                 temperature: 0,
@@ -344,16 +445,9 @@ public struct CodeCompletionService {
                 apiKey: apiKey,
                 contextWindow: model.info.maxTokens,
                 maxToken: UserDefaults.shared.value(for: \.customSuggestionMaxGenerationToken)
-            )
-            let result = try await service.getCompletions(
-                prompt,
-                streamStopStrategy: streamStopStrategy,
-                count: count
-            )
-            try Task.checkCancellation()
-            return result
+            ))
         case .ollama:
-            let service = OllamaService(
+            return .init(OllamaService(
                 url: model.endpoint,
                 endpoint: .completionWithSuffix,
                 modelName: model.info.modelName,
@@ -362,16 +456,9 @@ public struct CodeCompletionService {
                 stopWords: prompt.stopWords,
                 keepAlive: model.info.ollamaInfo.keepAlive,
                 format: .none
-            )
-            let result = try await service.getCompletions(
-                prompt,
-                streamStopStrategy: streamStopStrategy,
-                count: count
-            )
-            try Task.checkCancellation()
-            return result
+            ))
         case .ollamaCompatible:
-            let service = OllamaService(
+            return .init(OllamaService(
                 url: model.endpoint,
                 endpoint: .completionWithSuffix,
                 modelName: model.info.modelName,
@@ -391,14 +478,7 @@ public struct CodeCompletionService {
                         return .bearerToken(apiKey)
                     }
                 }()
-            )
-            let result = try await service.getCompletions(
-                prompt,
-                streamStopStrategy: streamStopStrategy,
-                count: count
-            )
-            try Task.checkCancellation()
-            return result
+            ))
         case .unknown:
             throw Error.unknownFormat
         }
diff --git a/Tool/Sources/CustomSuggestionService/CustomModelSuggestionService.swift b/Tool/Sources/CustomSuggestionService/CustomModelSuggestionService.swift
index 668bf6a6..12f6fb17 100644
--- a/Tool/Sources/CustomSuggestionService/CustomModelSuggestionService.swift
+++ b/Tool/Sources/CustomSuggestionService/CustomModelSuggestionService.swift
@@ -1,3 +1,4 @@
+import BuiltinExtension
 import CopilotForXcodeKit
 import enum CopilotForXcodeKit.SuggestionServiceError
 import Foundation
@@ -30,15 +31,40 @@ public final class CustomModelSuggestionService: SuggestionServiceType {
     ) async throws -> [CodeSuggestion] {
         do {
             return try await service.getSuggestions(request, workspace: workspace)
-        } catch is CancellationError {
-            return []
-        } catch let error as URLError where error.code == .cancelled {
-            return []
-        } catch let error as SuggestionServiceError {
-            throw error
         } catch {
-            throw SuggestionServiceError.notice(error)
+            if let mapped = Self.userFacingError(error) { throw mapped }
+            return []
         }
     }
+
+    /// Cancellation ends the round quietly; everything else reaches the user the same way on
+    /// both entry points.
+    static func userFacingError(_ error: Swift.Error) -> Swift.Error? {
+        if error is CancellationError { return nil }
+        if let urlError = error as? URLError, urlError.code == .cancelled { return nil }
+        if let error = error as? SuggestionServiceError { return error }
+        return SuggestionServiceError.notice(error)
+    }
 }
 
+extension CustomModelSuggestionService: StreamingSuggestionServiceType {
+    public func streamSuggestions(
+        _ request: SuggestionRequest,
+        workspace: WorkspaceInfo
+    ) async -> AsyncThrowingStream<[CodeSuggestion], Swift.Error> {
+        let upstream = await service.streamSuggestions(request, workspace: workspace)
+        return AsyncThrowingStream { continuation in
+            let task = Task {
+                do {
+                    for try await partial in upstream {
+                        continuation.yield(partial)
+                    }
+                    continuation.finish()
+                } catch {
+                    continuation.finish(throwing: Self.userFacingError(error))
+                }
+            }
+            continuation.onTermination = { _ in task.cancel() }
+        }
+    }
+}
diff --git a/Tool/Sources/CustomSuggestionService/CustomSuggestionCoordinator.swift b/Tool/Sources/CustomSuggestionService/CustomSuggestionCoordinator.swift
index 1afb0254..457c46c7 100644
--- a/Tool/Sources/CustomSuggestionService/CustomSuggestionCoordinator.swift
+++ b/Tool/Sources/CustomSuggestionService/CustomSuggestionCoordinator.swift
@@ -26,125 +26,86 @@ actor CustomSuggestionCoordinator {
         }
     }
 
-    var onGoingTask: Task<[CodeSuggestion], Error>?
+    /// Everything a completion round derives from the request and the preferences.
+    struct Round {
+        let request: SuggestionRequest
+        let prompt: PromptStrategy
+        let postProcessor: RawSuggestionPostProcessingStrategy
+        let stopStream: StreamStopStrategy
+        let model: Model
+        let maxLines: Int
+
+        /// Turns raw model output into the suggestion shown to the user; nil when it is blank.
+        func makeSuggestion(from rawSuggestion: String, id: String) -> CodeSuggestion? {
+            if rawSuggestion.allSatisfy({ $0.isWhitespace || $0.isNewline }) { return nil }
+            let suggestionText = postProcessor
+                .postProcess(
+                    rawSuggestion: rawSuggestion,
+                    infillPrefix: prompt.suggestionPrefix.prependingValue,
+                    suffix: prompt.suffix
+                )
+                .keepLines(count: maxLines)
+                .removeTrailingNewlinesAndWhitespace()
+
+            return CodeSuggestion(
+                id: id,
+                text: suggestionText,
+                position: request.cursorPosition,
+                range: .init(
+                    start: .init(
+                        line: request.cursorPosition.line,
+                        character: 0
+                    ),
+                    end: request.cursorPosition
+                )
+            )
+        }
+    }
+
+    /// Minimum interval between two partial emissions while streaming.
+    static let partialEmitInterval: TimeInterval = 0.05
+
+    /// Cancels the in-flight round, whichever entry point started it.
+    private var cancelOnGoingRound: (() -> Void)?
     var lastNoticeDate: Date?
 
     func cancelRequest() {
-        onGoingTask?.cancel()
-        onGoingTask = nil
+        cancelOnGoingRound?()
+        cancelOnGoingRound = nil
     }
 
     func getSuggestions(
         _ request: SuggestionRequest,
         workspace: WorkspaceInfo
     ) async throws -> [CodeSuggestion] {
-        onGoingTask?.cancel()
-        onGoingTask = nil
-        let snapshot = PreferenceSnapshot(
-            modelId: UserDefaults.shared.value(for: \.customSuggestionModelId),
-            chatModels: UserDefaults.shared.value(for: \.chatModels),
-            strategyId: UserDefaults.shared.value(for: \.customSuggestionRequestStrategyId)
-        )
+        cancelRequest()
+        let snapshot = Self.currentSnapshot()
         let task = Task {
             try await CodeCompletionLogger.$logger.withValue(.init(request: request)) {
                 do {
-                    let lines = request.content.breakLines()
-                    let (previousLines, nextLines) = Self.split(
-                        code: request.content,
-                        lines: lines,
-                        at: request.cursorPosition
-                    )
-                    let strategy = getStrategy(
-                        sourceRequest: request,
-                        prefix: previousLines,
-                        suffix: nextLines,
-                        snapshot: snapshot
-                    )
-
-                    if strategy.shouldSkip {
+                    guard let round = try prepareRound(request, snapshot: snapshot) else {
                         return [CodeSuggestion]()
                     }
 
-                    let service = CodeCompletionService()
-                    let model = try getModel(snapshot)
-                    let prompt = strategy.createPrompt()
-                    let postProcessor = strategy.createRawSuggestionPostProcessor()
-                    let stopStream = strategy.createStreamStopStrategy(model: model)
-
-                    let suggestedCodeSnippets: [String]
-
-                    switch model {
-                    case let .chatModel(model):
-                        CodeCompletionLogger.logger.logModel(model)
-                        suggestedCodeSnippets = try await service.getCompletions(
-                            prompt,
-                            streamStopStrategy: stopStream,
-                            model: model,
-                            count: 1
-                        )
-                    case let .completionModel(model):
-                        CodeCompletionLogger.logger.logModel(model)
-                        suggestedCodeSnippets = try await service.getCompletions(
-                            prompt,
-                            streamStopStrategy: stopStream,
-                            model: model,
-                            count: 1
-                        )
-                    case let .tabbyModel(model):
-                        CodeCompletionLogger.logger.logModel(model)
-                        suggestedCodeSnippets = try await service.getCompletions(
-                            prompt,
-                            streamStopStrategy: stopStream,
-                            model: model,
-                            count: 1
-                        )
-                    case let .fimModel(model):
-                        CodeCompletionLogger.logger.logModel(model)
-                        suggestedCodeSnippets = try await service.getCompletions(
-                            prompt,
-                            streamStopStrategy: stopStream,
-                            model: model,
-                            count: 1
-                        )
-                    }
+                    let suggestedCodeSnippets = try await CodeCompletionService().getCompletions(
+                        round.prompt,
+                        streamStopStrategy: round.stopStream,
+                        model: round.model,
+                        count: 1
+                    )
 
                     CodeCompletionLogger.logger.finish()
 
-                    return suggestedCodeSnippets
-                        .filter { !$0.allSatisfy { $0.isWhitespace || $0.isNewline } }
-                        .map {
-                            let suggestionText = postProcessor
-                                .postProcess(
-                                    rawSuggestion: $0,
-                                    infillPrefix: prompt.suggestionPrefix.prependingValue,
-                                    suffix: prompt.suffix
-                                )
-                                .keepLines(
-                                    count: UserDefaults.shared
-                                        .value(for: \.customSuggestionMaxLines)
-                                )
-                                .removeTrailingNewlinesAndWhitespace()
-
-                            return CodeSuggestion(
-                                id: UUID().uuidString,
-                                text: suggestionText,
-                                position: request.cursorPosition,
-                                range: .init(
-                                    start: .init(
-                                        line: request.cursorPosition.line,
-                                        character: 0
-                                    ),
-                                    end: request.cursorPosition
-                                )
-                            )
-                        }
+                    return suggestedCodeSnippets.compactMap {
+                        round.makeSuggestion(from: $0, id: UUID().uuidString)
+                    }
                 } catch {
                     CodeCompletionLogger.logger.error(error)
                     throw mapUserFacingError(error)
                 }
             }
         }
-        onGoingTask = task
+        cancelOnGoingRound = { task.cancel() }
         return try await withTaskCancellationHandler {
             try await task.value
         } onCancel: {
@@ -152,12 +113,118 @@ actor CustomSuggestionCoordinator {
         }
     }
 
+    /// Streams the round: partial suggestions (same id, growing text) at most every
+    /// ``partialEmitInterval``, then the final one. Blank partials are skipped.
+    func streamSuggestions(
+        _ request: SuggestionRequest,
+        workspace: WorkspaceInfo
+    ) -> AsyncThrowingStream<[CodeSuggestion], Swift.Error> {
+        cancelRequest()
+        let snapshot = Self.currentSnapshot()
+        return AsyncThrowingStream { continuation in
+            let task = Task {
+                await CodeCompletionLogger.$logger.withValue(.init(request: request)) {
+                    do {
+                        guard let round = try prepareRound(request, snapshot: snapshot) else {
+                            continuation.finish()
+                            return
+                        }
+                        let id = UUID().uuidString
+                        let completion = try await CodeCompletionService().streamCompletion(
+                            round.prompt,
+                            streamStopStrategy: round.stopStream,
+                            model: round.model
+                        )
+
+                        var latest: String?
+                        var lastEmit = Date.distantPast
+                        for try await partial in completion {
+                            try Task.checkCancellation()
+                            latest = partial
+                            let now = Date()
+                            guard now.timeIntervalSince(lastEmit) >= Self.partialEmitInterval,
+                                  let suggestion = round.makeSuggestion(from: partial, id: id)
+                            else { continue }
+                            lastEmit = now
+                            continuation.yield([suggestion])
+                            CodeCompletionLogger.logger.logPartial()
+                        }
+                        try Task.checkCancellation()
+
+                        if let latest { CodeCompletionLogger.logger.logResponse(latest) }
+                        CodeCompletionLogger.logger.finish()
+
+                        let final = latest.flatMap { round.makeSuggestion(from: $0, id: id) }
+                        continuation.yield(final.map { [$0] } ?? [])
+                        continuation.finish()
+                    } catch {
+                        CodeCompletionLogger.logger.error(error)
+                        continuation.finish(throwing: mapUserFacingError(error))
+                    }
+                }
+            }
+            cancelOnGoingRound = { task.cancel() }
+            continuation.onTermination = { _ in task.cancel() }
+        }
+    }
+
     struct PreferenceSnapshot {
         var modelId: String
         var chatModels: [ChatModel]
         var strategyId: String
     }
 
+    static func currentSnapshot() -> PreferenceSnapshot {
+        PreferenceSnapshot(
+            modelId: UserDefaults.shared.value(for: \.customSuggestionModelId),
+            chatModels: UserDefaults.shared.value(for: \.chatModels),
+            strategyId: UserDefaults.shared.value(for: \.customSuggestionRequestStrategyId)
+        )
+    }
+
+    /// Nil when the strategy decides there is nothing to complete here.
+    func prepareRound(_ request: SuggestionRequest, snapshot: PreferenceSnapshot) throws -> Round? {
+        let lines = request.content.breakLines()
+        let (previousLines, nextLines) = Self.split(
+            code: request.content,
+            lines: lines,
+            at: request.cursorPosition
+        )
+        let strategy = getStrategy(
+            sourceRequest: request,
+            prefix: previousLines,
+            suffix: nextLines,
+            snapshot: snapshot
+        )
+
+        if strategy.shouldSkip { return nil }
+
+        let model = try getModel(snapshot)
+        logModel(model)
+
+        return Round(
+            request: request,
+            prompt: strategy.createPrompt(),
+            postProcessor: strategy.createRawSuggestionPostProcessor(),
+            stopStream: strategy.createStreamStopStrategy(model: model),
+            model: model,
+            maxLines: UserDefaults.shared.value(for: \.customSuggestionMaxLines)
+        )
+    }
+
+    func logModel(_ model: Model) {
+        switch model {
+        case let .chatModel(model):
+            CodeCompletionLogger.logger.logModel(model)
+        case let .completionModel(model):
+            CodeCompletionLogger.logger.logModel(model)
+        case let .tabbyModel(model):
+            CodeCompletionLogger.logger.logModel(model)
+        case let .fimModel(model):
+            CodeCompletionLogger.logger.logModel(model)
+        }
+    }
+
     func getModel(_ snapshot: PreferenceSnapshot) throws -> Model {
         let id = snapshot.modelId
         let models = snapshot.chatModels
@@ -271,4 +338,3 @@ extension String {
         return String(text)
     }
 }
-
diff --git a/Tool/Sources/CustomSuggestionService/RawSuggestionPostProcessing/DefaultRawSuggestionPostProcessingStrategy.swift b/Tool/Sources/CustomSuggestionService/RawSuggestionPostProcessing/DefaultRawSuggestionPostProcessingStrategy.swift
index d62360dc..c612e683 100644
--- a/Tool/Sources/CustomSuggestionService/RawSuggestionPostProcessing/DefaultRawSuggestionPostProcessingStrategy.swift
+++ b/Tool/Sources/CustomSuggestionService/RawSuggestionPostProcessing/DefaultRawSuggestionPostProcessingStrategy.swift
@@ -11,10 +11,21 @@ struct DefaultRawSuggestionPostProcessingStrategy: RawSuggestionPostProcessingSt
     func postProcess(rawSuggestion: String, infillPrefix: String, suffix: [String]) -> String {
         var suggestion = extractSuggestion(from: rawSuggestion)
         removePrefix(from: &suggestion, infillPrefix: infillPrefix)
+        removeDuplicatedIndentation(from: &suggestion, infillPrefix: infillPrefix)
         removeSuffix(from: &suggestion, suffix: suffix)
         return infillPrefix + suggestion
     }
 
+    /// Models like to restate the indentation of the line they continue. When the prefix already
+    /// ends with whitespace, leading spaces or tabs in the completion would only double the gap,
+    /// and they make Tab-to-accept look like indentation typing.
+    func removeDuplicatedIndentation(from suggestion: inout String, infillPrefix: String) {
+        guard let last = infillPrefix.last, last == " " || last == "\t" else { return }
+        while let first = suggestion.first, first == " " || first == "\t" {
+            suggestion.removeFirst()
+        }
+    }
+
     func extractSuggestion(from response: String) -> String {
         let escapedMarkdownCodeBlock = removeLeadingAndTrailingMarkdownCodeBlockMark(from: response)
         if let tags = codeWrappingTags {
diff --git a/Tool/Sources/Preferences/CustomSuggestionChatModelAPIOptions.swift b/Tool/Sources/Preferences/CustomSuggestionChatModelAPIOptions.swift
index c4ac7665..3ee05c2d 100644
--- a/Tool/Sources/Preferences/CustomSuggestionChatModelAPIOptions.swift
+++ b/Tool/Sources/Preferences/CustomSuggestionChatModelAPIOptions.swift
@@ -1,6 +1,7 @@
 import Foundation
 
-/// Which OpenAI endpoint to call for chat models.
+/// Which OpenAI endpoint to call for chat models. Defaults to `/v1/responses`; models whose
+/// server only speaks `/v1/chat/completions` need the endpoint picker switched per model.
 public enum OpenAIChatAPI: String, Codable, CaseIterable, Equatable {
     /// `POST /v1/chat/completions`
     case chatCompletions
@@ -36,7 +37,7 @@ public struct ChatModelAPIOptions: Codable, Equatable {
     public var reasoningTokenBudget: Int
 
     public init(
-        api: OpenAIChatAPI = .chatCompletions,
+        api: OpenAIChatAPI = .responses,
         reasoningEffort: ReasoningEffort? = nil,
         reasoningTokenBudget: Int = 1500
     ) {
@@ -47,7 +48,7 @@ public struct ChatModelAPIOptions: Codable, Equatable {
 
     public init(from decoder: Decoder) throws {
         let container = try decoder.container(keyedBy: CodingKeys.self)
-        api = try container.decodeIfPresent(OpenAIChatAPI.self, forKey: .api) ?? .chatCompletions
+        api = try container.decodeIfPresent(OpenAIChatAPI.self, forKey: .api) ?? .responses
         reasoningEffort = try container.decodeIfPresent(
             ReasoningEffort.self,
             forKey: .reasoningEffort
@@ -96,12 +97,12 @@ public struct ChatModelAPIOptions: Codable, Equatable {
     ) -> Bool {
         let oldAPI = defaults.value(forKey: legacyOpenAIAPIKey) as? String
         let oldEffort = defaults.value(forKey: legacyReasoningEffortKey) as? String
-        let hasNonDefaultAPI = oldAPI != nil && oldAPI != "chatCompletions"
+        let hasNonDefaultAPI = oldAPI != nil
         let hasNonDefaultEffort = !(oldEffort ?? "").isEmpty
         guard hasNonDefaultAPI || hasNonDefaultEffort else { return false }
 
         dict[modelId] = ChatModelAPIOptions(
-            api: OpenAIChatAPI(rawValue: oldAPI ?? "") ?? .chatCompletions,
+            api: OpenAIChatAPI(rawValue: oldAPI ?? "") ?? .responses,
             reasoningEffort: oldEffort.flatMap(ReasoningEffort.init(rawValue:))
         )
         defaults.set(nil, forKey: legacyOpenAIAPIKey)
diff --git a/Tool/Sources/SuggestionProvider/NeighboringSnippetRetriever.swift b/Tool/Sources/SuggestionProvider/NeighboringSnippetRetriever.swift
new file mode 100644
index 00000000..aecfc0ca
--- /dev/null
+++ b/Tool/Sources/SuggestionProvider/NeighboringSnippetRetriever.swift
@@ -0,0 +1,183 @@
+import Foundation
+import SuggestionBasic
+
+/// Splits code into the identifier-ish tokens used to compare two pieces of code.
+public enum CodeTokenizer {
+    /// Words that appear in almost every file and so carry no signal.
+    static let stopWords: Set = [
+        "self", "true", "false", "nil", "null", "none", "let", "var", "func", "class", "struct",
+        "enum", "protocol", "extension", "import", "return", "if", "else", "guard", "for", "while",
+        "in", "case", "switch", "default", "break", "continue", "do", "try", "catch", "throw",
+        "throws", "async", "await", "public", "private", "internal", "fileprivate", "static",
+        "final", "override", "init", "deinit", "get", "set", "where", "as", "is", "new", "this",
+        "const", "int", "string", "bool", "void", "def", "end", "type", "value", "data",
+    ]
+
+    /// Tokens shorter than this are noise (`i`, `x`, `id` …).
+    static let minimumLength = 3
+
+    public static func tokens(in lines: S) -> Set where S.Element == String {
+        var result = Set()
+        for line in lines {
+            var current = ""
+            for character in line {
+                if character.isLetter || character.isNumber || character == "_" {
+                    current.append(character)
+                } else {
+                    add(current, to: &result)
+                    current = ""
+                }
+            }
+            add(current, to: &result)
+        }
+        return result
+    }
+
+    private static func add(_ token: String, to set: inout Set) {
+        guard token.count >= minimumLength else { return }
+        let lowered = token.lowercased()
+        guard !stopWords.contains(lowered) else { return }
+        set.insert(lowered)
+    }
+}
+
+/// A file that has been prepared once so it can be scored cheaply on every keystroke.
+public struct IndexedSnippetSource {
+    public struct Window {
+        public var startLine: Int
+        public var endLine: Int
+        public var tokens: Set
+    }
+
+    public var filePath: String
+    public var lines: [String]
+    public var windows: [Window]
+
+    public init(filePath: String, lines: [String], windows: [Window]) {
+        self.filePath = filePath
+        self.lines = lines
+        self.windows = windows
+    }
+}
+
+/// Copilot-style "neighboring tabs" retrieval.
+///
+/// Every other opened file is cut into fixed windows, each window is scored against the code
+/// around the cursor with Jaccard similarity over identifier tokens, and the best windows become
+/// reference snippets. It is deliberately index-free: no embeddings, no network, no warm-up.
+public enum NeighboringSnippetRetriever {
+    public struct Options {
+        /// How many lines before the cursor form the query.
+        public var referenceWindow: Int
+        public var windowSize: Int
+        public var windowStride: Int
+        public var maxSnippets: Int
+        public var maxSnippetsPerFile: Int
+        public var minimumScore: Double
+
+        public init(
+            referenceWindow: Int = 40,
+            windowSize: Int = 30,
+            windowStride: Int = 15,
+            maxSnippets: Int = 3,
+            maxSnippetsPerFile: Int = 1,
+            minimumScore: Double = 0.1
+        ) {
+            self.referenceWindow = referenceWindow
+            self.windowSize = windowSize
+            self.windowStride = windowStride
+            self.maxSnippets = maxSnippets
+            self.maxSnippetsPerFile = maxSnippetsPerFile
+            self.minimumScore = minimumScore
+        }
+    }
+
+    public static func index(
+        filePath: String,
+        lines: [String],
+        options: Options = .init()
+    ) -> IndexedSnippetSource {
+        var windows = [IndexedSnippetSource.Window]()
+        guard !lines.isEmpty else {
+            return .init(filePath: filePath, lines: lines, windows: windows)
+        }
+        let size = max(1, options.windowSize)
+        let stride = max(1, options.windowStride)
+        var start = 0
+        while start < lines.count {
+            let end = min(lines.count, start + size)
+            windows.append(.init(
+                startLine: start,
+                endLine: end,
+                tokens: CodeTokenizer.tokens(in: lines[start.. Set {
+        guard !currentLines.isEmpty else { return [] }
+        let cursorLine = min(max(0, cursorPosition.line), currentLines.count - 1)
+        let start = max(0, cursorLine - options.referenceWindow)
+        let end = min(currentLines.count, cursorLine + 1)
+        guard start < end else { return [] }
+        return CodeTokenizer.tokens(in: currentLines[start.., _ rhs: Set) -> Double {
+        if lhs.isEmpty || rhs.isEmpty { return 0 }
+        let intersection = lhs.intersection(rhs).count
+        if intersection == 0 { return 0 }
+        let union = lhs.count + rhs.count - intersection
+        return Double(intersection) / Double(union)
+    }
+
+    public static func retrieve(
+        query: Set,
+        sources: [IndexedSnippetSource],
+        commentPrefix: String = "//",
+        options: Options = .init()
+    ) -> [RelevantCodeSnippet] {
+        guard !query.isEmpty, options.maxSnippets > 0 else { return [] }
+
+        struct Match {
+            var filePath: String
+            var lines: ArraySlice
+            var score: Double
+        }
+
+        var matches = [Match]()
+        for source in sources {
+            var best = [Match]()
+            for window in source.windows {
+                let score = jaccardSimilarity(query, window.tokens)
+                guard score >= options.minimumScore else { continue }
+                best.append(.init(
+                    filePath: source.filePath,
+                    lines: source.lines[window.startLine.. $1.score }
+            matches.append(contentsOf: best.prefix(max(1, options.maxSnippetsPerFile)))
+        }
+
+        matches.sort { $0.score > $1.score }
+        let chosen = matches.prefix(options.maxSnippets)
+
+        return chosen.enumerated().map { index, match in
+            RelevantCodeSnippet(
+                content: "\(commentPrefix) Path: \(match.filePath)\n"
+                    + match.lines.joined(separator: "\n"),
+                priority: chosen.count - index,
+                filePath: match.filePath
+            )
+        }
+    }
+}
diff --git a/Tool/Sources/SuggestionProvider/RecentEditSnippet.swift b/Tool/Sources/SuggestionProvider/RecentEditSnippet.swift
new file mode 100644
index 00000000..c96a6da0
--- /dev/null
+++ b/Tool/Sources/SuggestionProvider/RecentEditSnippet.swift
@@ -0,0 +1,137 @@
+import Foundation
+
+/// Line comment token per language, so injected context stays valid code wherever it lands.
+public enum CodeCommentStyle {
+    public static func prefix(forFileExtension fileExtension: String) -> String {
+        switch fileExtension.lowercased() {
+        case "py", "rb", "sh", "bash", "zsh", "yml", "yaml", "toml", "pl", "r", "cmake":
+            return "#"
+        case "sql", "lua", "hs", "elm", "applescript":
+            return "--"
+        default:
+            return "//"
+        }
+    }
+}
+
+/// One observed change to a file: the lines that were there and the lines that replaced them.
+public struct RecentEditHunk: Equatable {
+    public var filePath: String
+    public var startLine: Int
+    public var removed: [String]
+    public var inserted: [String]
+
+    public init(filePath: String, startLine: Int, removed: [String], inserted: [String]) {
+        self.filePath = filePath
+        self.startLine = startLine
+        self.removed = removed
+        self.inserted = inserted
+    }
+}
+
+public enum RecentEditDiff {
+    /// Diffs two versions of a file by trimming the common head and tail.
+    ///
+    /// Suggestion rounds fire while the user types, so the delta between two rounds is almost
+    /// always a single contiguous edit. That makes the cheap trim as accurate as a real diff here,
+    /// and it stays O(n) on every keystroke.
+    public static func hunk(
+        filePath: String,
+        before: [String],
+        after: [String]
+    ) -> RecentEditHunk? {
+        let normalizedBefore = before.map(trimNewline)
+        let normalizedAfter = after.map(trimNewline)
+
+        var head = 0
+        while head < normalizedBefore.count,
+              head < normalizedAfter.count,
+              normalizedBefore[head] == normalizedAfter[head]
+        {
+            head += 1
+        }
+
+        var tail = 0
+        while tail < normalizedBefore.count - head,
+              tail < normalizedAfter.count - head,
+              normalizedBefore[normalizedBefore.count - 1 - tail]
+              == normalizedAfter[normalizedAfter.count - 1 - tail]
+        {
+            tail += 1
+        }
+
+        let removed = Array(normalizedBefore[head..<(normalizedBefore.count - tail)])
+        let inserted = Array(normalizedAfter[head..<(normalizedAfter.count - tail)])
+        guard !(removed.isEmpty && inserted.isEmpty) else { return nil }
+
+        return .init(
+            filePath: filePath,
+            startLine: head,
+            removed: removed,
+            inserted: inserted
+        )
+    }
+
+    static func trimNewline(_ line: String) -> String {
+        var line = line
+        while let last = line.last, last.isNewline { line.removeLast() }
+        return line
+    }
+}
+
+/// Renders recent edits as a comment block.
+///
+/// This is the one signal Cursor Tab has that plain retrieval does not: knowing what the user just
+/// changed usually says more about the next edit than anything else in the file.
+public enum RecentEditSnippetBuilder {
+    public static let header =
+        "The user's most recent edits, oldest first. They show what the user is doing right now."
+
+    public static func snippet(
+        from hunks: [RecentEditHunk],
+        commentPrefix: String = "//",
+        priority: Int,
+        maxLinesPerHunk: Int = 8,
+        maxHunks: Int = 3
+    ) -> RelevantCodeSnippet? {
+        let hunks = Array(hunks.suffix(maxHunks))
+        guard !hunks.isEmpty else { return nil }
+
+        var lines = ["\(commentPrefix) \(header)"]
+        for hunk in hunks {
+            lines.append("\(commentPrefix) --- \(hunk.filePath)")
+            lines.append(contentsOf: render(
+                hunk.removed,
+                marker: "-",
+                commentPrefix: commentPrefix,
+                maxLines: maxLinesPerHunk
+            ))
+            lines.append(contentsOf: render(
+                hunk.inserted,
+                marker: "+",
+                commentPrefix: commentPrefix,
+                maxLines: maxLinesPerHunk
+            ))
+        }
+
+        return .init(
+            content: lines.joined(separator: "\n"),
+            priority: priority,
+            filePath: hunks.last?.filePath ?? ""
+        )
+    }
+
+    private static func render(
+        _ lines: [String],
+        marker: String,
+        commentPrefix: String,
+        maxLines: Int
+    ) -> [String] {
+        guard !lines.isEmpty else { return [] }
+        var rendered = lines.prefix(maxLines).map { "\(commentPrefix) \(marker) \($0)" }
+        if lines.count > maxLines {
+            rendered.append("\(commentPrefix) \(marker) … \(lines.count - maxLines) more lines")
+        }
+        return rendered
+    }
+}
diff --git a/Tool/Sources/SuggestionProvider/StreamingSuggestionServiceProvider.swift b/Tool/Sources/SuggestionProvider/StreamingSuggestionServiceProvider.swift
new file mode 100644
index 00000000..5203b01e
--- /dev/null
+++ b/Tool/Sources/SuggestionProvider/StreamingSuggestionServiceProvider.swift
@@ -0,0 +1,57 @@
+import struct CopilotForXcodeKit.WorkspaceInfo
+import Foundation
+import SuggestionBasic
+
+/// A provider that can hand out partial suggestions while the model is still generating.
+///
+/// Every element of the stream replaces the previous one: the text is cumulative and the ids are
+/// stable for the whole round. The last element is the final result and equals what
+/// ``SuggestionServiceProvider/getSuggestions(_:workspaceInfo:)`` would have returned.
+public protocol StreamingSuggestionServiceProvider: SuggestionServiceProvider {
+    func streamSuggestions(
+        _ request: SuggestionRequest,
+        workspaceInfo: WorkspaceInfo
+    ) async -> AsyncThrowingStream<[CodeSuggestion], Error>
+}
+
+/// Helpers to build suggestion streams without repeating the task / continuation dance.
+public enum SuggestionStreams {
+    /// A stream that emits the result of one async operation and finishes.
+    public static func single(
+        _ operation: @escaping () async throws -> Element
+    ) -> AsyncThrowingStream {
+        AsyncThrowingStream { continuation in
+            let task = Task {
+                do {
+                    continuation.yield(try await operation())
+                    continuation.finish()
+                } catch {
+                    continuation.finish(throwing: error)
+                }
+            }
+            continuation.onTermination = { _ in task.cancel() }
+        }
+    }
+
+    /// Forwards `upstream`, transforming every element. Dropping the consumer cancels `upstream`.
+    public static func forward(
+        _ upstream: Upstream,
+        mapError: @escaping (Error) -> Error = { $0 },
+        transform: @escaping (Upstream.Element) async throws -> Element
+    ) -> AsyncThrowingStream {
+        AsyncThrowingStream { continuation in
+            let task = Task {
+                do {
+                    for try await element in upstream {
+                        try Task.checkCancellation()
+                        continuation.yield(try await transform(element))
+                    }
+                    continuation.finish()
+                } catch {
+                    continuation.finish(throwing: mapError(error))
+                }
+            }
+            continuation.onTermination = { _ in task.cancel() }
+        }
+    }
+}
diff --git a/Tool/Sources/WorkspaceSuggestionService/SuggestionContextCollector.swift b/Tool/Sources/WorkspaceSuggestionService/SuggestionContextCollector.swift
new file mode 100644
index 00000000..7c22465e
--- /dev/null
+++ b/Tool/Sources/WorkspaceSuggestionService/SuggestionContextCollector.swift
@@ -0,0 +1,169 @@
+import Foundation
+import SuggestionBasic
+import SuggestionProvider
+import Workspace
+
+/// Builds the reference snippets that go into a suggestion request.
+///
+/// The custom-model service already advertises `acceptsRelevantCodeSnippets`, but nothing ever
+/// filled that array, so the model only ever saw the file being edited. This collects two things
+/// that the products people compare us to both rely on:
+///
+/// - Neighboring tabs: the most similar windows of the other opened files, scored with Jaccard
+///   similarity the way GitHub Copilot does it. No index, no embeddings, no extra round trip.
+/// - Recent edits: what the user changed in the last few rounds, which is the signal Cursor Tab
+///   leans on to guess where the current edit is going.
+@WorkspaceActor
+final class SuggestionContextCollector {
+    static let shared = SuggestionContextCollector()
+
+    /// Reading and tokenizing every opened file on each keystroke would be wasteful, so a file is
+    /// only re-read when its size or modification date changes.
+    private struct CachedSource {
+        var modificationDate: Date
+        var size: Int
+        var source: IndexedSnippetSource
+    }
+
+    private var sourceCache = [URL: CachedSource]()
+    private var lastSeenLines = [URL: [String]]()
+    private var recentEdits = [RecentEditHunk]()
+
+    /// Files larger than this are generated code or data, not useful references.
+    private let maximumFileSize = 256 * 1024
+    private let maximumCandidateFiles = 12
+    private let maximumRecentEdits = 3
+    private let retrievalOptions = NeighboringSnippetRetriever.Options()
+
+    func collectSnippets(
+        for fileURL: URL,
+        lines: [String],
+        cursorPosition: CursorPosition,
+        workspace: Workspace
+    ) -> [RelevantCodeSnippet] {
+        recordEdit(for: fileURL, lines: lines, projectRootURL: workspace.projectRootURL)
+
+        let commentPrefix = CodeCommentStyle.prefix(forFileExtension: fileURL.pathExtension)
+        var snippets = [RelevantCodeSnippet]()
+
+        if let editSnippet = RecentEditSnippetBuilder.snippet(
+            from: recentEdits,
+            commentPrefix: commentPrefix,
+            // Recent edits outrank retrieved code: they are about the change in progress.
+            priority: retrievalOptions.maxSnippets + 1
+        ) {
+            snippets.append(editSnippet)
+        }
+
+        let query = NeighboringSnippetRetriever.queryTokens(
+            currentLines: lines,
+            cursorPosition: cursorPosition,
+            options: retrievalOptions
+        )
+        snippets.append(contentsOf: NeighboringSnippetRetriever.retrieve(
+            query: query,
+            sources: indexedSources(excluding: fileURL, workspace: workspace),
+            commentPrefix: commentPrefix,
+            options: retrievalOptions
+        ))
+
+        return snippets
+    }
+
+    private func recordEdit(for fileURL: URL, lines: [String], projectRootURL: URL) {
+        defer { lastSeenLines[fileURL] = lines }
+        guard let previous = lastSeenLines[fileURL] else { return }
+        guard let hunk = RecentEditDiff.hunk(
+            filePath: relativePath(of: fileURL, in: projectRootURL),
+            before: previous,
+            after: lines
+        ) else { return }
+
+        // The trailing edit is usually the same one growing character by character; replacing it
+        // keeps the history at "the last few distinct places the user touched".
+        if let last = recentEdits.last, last.startLine == hunk.startLine,
+           last.filePath == hunk.filePath
+        {
+            recentEdits[recentEdits.endIndex - 1] = hunk
+        } else {
+            recentEdits.append(hunk)
+        }
+        if recentEdits.count > maximumRecentEdits {
+            recentEdits.removeFirst(recentEdits.count - maximumRecentEdits)
+        }
+    }
+
+    private func indexedSources(
+        excluding fileURL: URL,
+        workspace: Workspace
+    ) -> [IndexedSnippetSource] {
+        // Filespaces only cover what this service process has seen, which is nothing right after
+        // a restart. The persisted list of opened files fills that gap; it has no recency order,
+        // so it goes last and only tops the list up.
+        let recentlyEdited = workspace.filespaces.values
+            .filter { $0.fileURL != fileURL && $0.isTextReadable }
+            .sorted { $0.lastUpdateTime > $1.lastUpdateTime }
+            .map(\.fileURL)
+        let previouslyOpened = workspace.openedFileRecoverableStorage.openedFiles
+            .filter { $0 != fileURL }
+
+        var candidates = [URL]()
+        var seen = Set()
+        for url in recentlyEdited + previouslyOpened where seen.insert(url).inserted {
+            candidates.append(url)
+            if candidates.count >= maximumCandidateFiles { break }
+        }
+
+        pruneCaches(keeping: seen.union([fileURL]))
+
+        return candidates.compactMap {
+            indexedSource(for: $0, projectRootURL: workspace.projectRootURL)
+        }
+    }
+
+    private func indexedSource(for fileURL: URL, projectRootURL: URL) -> IndexedSnippetSource? {
+        guard let attributes = try? FileManager.default
+            .attributesOfItem(atPath: fileURL.path),
+            let size = attributes[.size] as? Int,
+            size > 0, size <= maximumFileSize
+        else { return nil }
+        let modificationDate = attributes[.modificationDate] as? Date ?? .distantPast
+
+        if let cached = sourceCache[fileURL],
+           cached.size == size,
+           cached.modificationDate == modificationDate
+        {
+            return cached.source
+        }
+
+        guard let content = try? String(contentsOf: fileURL, encoding: .utf8) else { return nil }
+        let lines = content.components(separatedBy: "\n").map { line -> String in
+            line.hasSuffix("\r") ? String(line.dropLast()) : line
+        }
+        let source = NeighboringSnippetRetriever.index(
+            filePath: relativePath(of: fileURL, in: projectRootURL),
+            lines: lines,
+            options: retrievalOptions
+        )
+        sourceCache[fileURL] = .init(
+            modificationDate: modificationDate,
+            size: size,
+            source: source
+        )
+        return source
+    }
+
+    /// Keeps both caches bounded by the files that can still be picked as candidates, so a
+    /// closed file's content and edit history are dropped without a separate eviction policy.
+    private func pruneCaches(keeping fileURLs: Set) {
+        sourceCache = sourceCache.filter { fileURLs.contains($0.key) }
+        lastSeenLines = lastSeenLines.filter { fileURLs.contains($0.key) }
+    }
+
+    private func relativePath(of fileURL: URL, in projectRootURL: URL) -> String {
+        let path = fileURL.path
+        let root = projectRootURL.path
+        guard root != "/", path.hasPrefix(root) else { return fileURL.lastPathComponent }
+        return String(path.dropFirst(root.count))
+    }
+}
diff --git a/Tool/Sources/WorkspaceSuggestionService/Workspace+StreamingSuggestion.swift b/Tool/Sources/WorkspaceSuggestionService/Workspace+StreamingSuggestion.swift
new file mode 100644
index 00000000..c0b24039
--- /dev/null
+++ b/Tool/Sources/WorkspaceSuggestionService/Workspace+StreamingSuggestion.swift
@@ -0,0 +1,99 @@
+import Foundation
+import SuggestionBasic
+import SuggestionProvider
+import Workspace
+import XPCShared
+
+public extension Workspace {
+    /// Streaming variant of ``generateSuggestions(forFileAt:editor:)``.
+    ///
+    /// The filespace is updated with every partial result and `onPartial` is called right after,
+    /// so the caller can refresh the widget while the model is still writing. The loop ends as
+    /// soon as the round is over from the user's point of view (accepted, rejected, dismissed or
+    /// invalidated by typing), which also cancels the request. Providers that can't stream go
+    /// through the blocking path unchanged.
+    @WorkspaceActor
+    @discardableResult
+    func generateSuggestionsStreaming(
+        forFileAt fileURL: URL,
+        editor: EditorContent,
+        onPartial: (CodeSuggestion) -> Void
+    ) async throws -> [CodeSuggestion] {
+        guard let streamingService = suggestionService as? StreamingSuggestionServiceProvider
+        else {
+            return try await generateSuggestions(forFileAt: fileURL, editor: editor)
+        }
+
+        refreshUpdateTime()
+
+        let filespace = try createFilespaceIfNeeded(fileURL: fileURL)
+
+        guard !(await filespace.isGitIgnored) else { return [] }
+
+        if !editor.uti.isEmpty {
+            filespace.codeMetadata.uti = editor.uti
+            filespace.codeMetadata.tabSize = editor.tabSize
+            filespace.codeMetadata.indentSize = editor.indentSize
+            filespace.codeMetadata.usesTabsForIndentation = editor.usesTabsForIndentation
+        }
+
+        filespace.codeMetadata.guessLineEnding(from: editor.lines.first)
+
+        let snapshot = FilespaceSuggestionSnapshot(
+            lines: editor.lines,
+            cursorPosition: editor.cursorPosition
+        )
+
+        filespace.suggestionSourceSnapshot = snapshot
+
+        let content = editor.lines.joined(separator: "")
+        let relevantCodeSnippets = SuggestionContextCollector.shared.collectSnippets(
+            for: fileURL,
+            lines: editor.lines,
+            cursorPosition: editor.cursorPosition,
+            workspace: self
+        )
+        let stream = await streamingService.streamSuggestions(
+            .init(
+                fileURL: fileURL,
+                relativePath: fileURL.path.replacingOccurrences(of: projectRootURL.path, with: ""),
+                content: content,
+                originalContent: content,
+                lines: editor.lines,
+                cursorPosition: editor.cursorPosition,
+                cursorOffset: editor.cursorOffset,
+                tabSize: editor.tabSize,
+                indentSize: editor.indentSize,
+                usesTabsForIndentation: editor.usesTabsForIndentation,
+                relevantCodeSnippets: relevantCodeSnippets
+            ),
+            workspaceInfo: .init(workspaceURL: workspaceURL, projectURL: projectRootURL)
+        )
+
+        var latest = [CodeSuggestion]()
+        var presentedID: String?
+        for try await partial in stream {
+            if Task.isCancelled { break }
+            guard Self.roundIsStillOpen(filespace, snapshot: snapshot, presentedID: presentedID)
+            else { break }
+            latest = partial
+            guard let first = partial.first else { continue }
+            filespace.setSuggestions(partial)
+            presentedID = first.id
+            onPartial(first)
+        }
+        return latest
+    }
+
+    /// False once accept / reject / dismiss / invalidation has reset the round.
+    @WorkspaceActor
+    private static func roundIsStillOpen(
+        _ filespace: Filespace,
+        snapshot: FilespaceSuggestionSnapshot,
+        presentedID: String?
+    ) -> Bool {
+        guard filespace.suggestionSourceSnapshot == snapshot else { return false }
+        guard let presentedID else { return true }
+        return filespace.presentingSuggestion?.id == presentedID
+    }
+}
diff --git a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift
index 99abe305..a1c686a4 100644
--- a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift
+++ b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift
@@ -55,6 +55,12 @@ public extension Workspace {
 
         guard let suggestionService else { throw SuggestionFeatureDisabledError() }
         let content = editor.lines.joined(separator: "")
+        let relevantCodeSnippets = SuggestionContextCollector.shared.collectSnippets(
+            for: fileURL,
+            lines: editor.lines,
+            cursorPosition: editor.cursorPosition,
+            workspace: self
+        )
         let completions = try await suggestionService.getSuggestions(
             .init(
                 fileURL: fileURL,
@@ -67,7 +73,7 @@ public extension Workspace {
                 tabSize: editor.tabSize,
                 indentSize: editor.indentSize,
                 usesTabsForIndentation: editor.usesTabsForIndentation,
-                relevantCodeSnippets: []
+                relevantCodeSnippets: relevantCodeSnippets
             ),
             workspaceInfo: .init(workspaceURL: workspaceURL, projectURL: projectRootURL)
         )
diff --git a/Tool/Tests/CustomSuggestionServiceTests/ChatAPIOptionsAndSemanticsTests.swift b/Tool/Tests/CustomSuggestionServiceTests/ChatAPIOptionsAndSemanticsTests.swift
index 0c6c3387..927a03f9 100644
--- a/Tool/Tests/CustomSuggestionServiceTests/ChatAPIOptionsAndSemanticsTests.swift
+++ b/Tool/Tests/CustomSuggestionServiceTests/ChatAPIOptionsAndSemanticsTests.swift
@@ -91,7 +91,7 @@ final class ChatAPIOptionsAndSemanticsTests: XCTestCase {
         XCTAssertNil(legacyDefaults.value(forKey: ChatModelAPIOptions.legacyReasoningEffortKey))
         XCTAssertEqual(
             ChatModelAPIOptions.stored(for: "model-2", defaults: legacyDefaults).api,
-            .chatCompletions
+            .responses
         )
     }
 
@@ -358,7 +358,7 @@ final class ChatAPIOptionsAndSemanticsTests: XCTestCase {
             apiKey: "sk",
             defaults: defaults
         )
-        XCTAssertEqual(options.api, .chatCompletions)
+        XCTAssertEqual(options.api, .responses)
         XCTAssertNil(options.reasoningEffort)
         XCTAssertEqual(options.reasoningTokenBudget, 1500)
         XCTAssertEqual(options.extraHeaders.map(\.name), ["X-Ok"])
@@ -373,7 +373,7 @@ final class ChatAPIOptionsAndSemanticsTests: XCTestCase {
             apiKey: "sk",
             defaults: defaults
         )
-        XCTAssertEqual(invalidStored.api, .chatCompletions)
+        XCTAssertEqual(invalidStored.api, .responses)
         XCTAssertNil(invalidStored.reasoningEffort)
         XCTAssertEqual(invalidStored.reasoningTokenBudget, 1500)
 
@@ -382,7 +382,7 @@ final class ChatAPIOptionsAndSemanticsTests: XCTestCase {
         legacyDefaults.set("not-an-api", forKey: ChatModelAPIOptions.legacyOpenAIAPIKey)
         legacyDefaults.set("not-an-effort", forKey: ChatModelAPIOptions.legacyReasoningEffortKey)
         let migrated = ChatModelAPIOptions.stored(for: "model-defaults", defaults: legacyDefaults)
-        XCTAssertEqual(migrated.api, .chatCompletions)
+        XCTAssertEqual(migrated.api, .responses)
         XCTAssertNil(migrated.reasoningEffort)
         XCTAssertNil(OpenAIChatAPI(rawValue: "not-an-api"))
         XCTAssertNil(ReasoningEffort(rawValue: "not-an-effort"))
diff --git a/Tool/Tests/CustomSuggestionServiceTests/DuplicatedIndentationPostProcessingTests.swift b/Tool/Tests/CustomSuggestionServiceTests/DuplicatedIndentationPostProcessingTests.swift
new file mode 100644
index 00000000..e4ba3dca
--- /dev/null
+++ b/Tool/Tests/CustomSuggestionServiceTests/DuplicatedIndentationPostProcessingTests.swift
@@ -0,0 +1,43 @@
+import Foundation
+import XCTest
+
+@testable import CustomSuggestionService
+
+final class DuplicatedIndentationPostProcessingTests: XCTestCase {
+    let strategy = DefaultRawSuggestionPostProcessingStrategy(
+        codeWrappingTags: ("", "")
+    )
+
+    func test_dropsLeadingWhitespaceWhenThePrefixAlreadyEndsWithWhitespace() {
+        let result = strategy.postProcess(
+            rawSuggestion: "        ))\nlet shareItem = 1",
+            infillPrefix: "/// ",
+            suffix: []
+        )
+        XCTAssertEqual(result, "/// ))\nlet shareItem = 1")
+    }
+
+    func test_dropsExtraIndentationOnAnAutoIndentedEmptyLine() {
+        let result = strategy.postProcess(
+            rawSuggestion: "    let a = 1",
+            infillPrefix: "        ",
+            suffix: []
+        )
+        XCTAssertEqual(result, "        let a = 1")
+    }
+
+    func test_keepsLeadingWhitespaceWhenThePrefixIsEmpty() {
+        let result = strategy.postProcess(rawSuggestion: "    let a = 1", infillPrefix: "", suffix: [])
+        XCTAssertEqual(result, "    let a = 1")
+    }
+
+    func test_keepsLeadingWhitespaceWhenThePrefixEndsWithNonWhitespace() {
+        let result = strategy.postProcess(rawSuggestion: " = 1", infillPrefix: "let a", suffix: [])
+        XCTAssertEqual(result, "let a = 1")
+    }
+
+    func test_dropsTabsToo() {
+        let result = strategy.postProcess(rawSuggestion: "\t\tfoo()", infillPrefix: "\t", suffix: [])
+        XCTAssertEqual(result, "\tfoo()")
+    }
+}
diff --git a/Tool/Tests/CustomSuggestionServiceTests/StreamingCompletionTests.swift b/Tool/Tests/CustomSuggestionServiceTests/StreamingCompletionTests.swift
new file mode 100644
index 00000000..bb47f948
--- /dev/null
+++ b/Tool/Tests/CustomSuggestionServiceTests/StreamingCompletionTests.swift
@@ -0,0 +1,148 @@
+import CopilotForXcodeKit
+import Foundation
+import XCTest
+
+@testable import CustomSuggestionService
+
+final class StreamingCompletionTests: XCTestCase {
+    private func tokens(_ parts: [String]) -> AsyncThrowingStream {
+        AsyncThrowingStream { continuation in
+            for part in parts { continuation.yield(part) }
+            continuation.finish()
+        }
+    }
+
+    func test_displayablePrefix_holdsBackWhatCouldStillBecomeAStopWord() {
+        let stopWords = ["", "\n\n"]
+        XCTAssertEqual(
+            CompletionStopWords.displayablePrefix(of: "let a = 1", "junk"]),
+            stopWords: ["", "\n\n"],
+            limiter: limiter
+        ) { partials.append($0) }
+
+        XCTAssertEqual(result, "let a = 1\n")
+        XCTAssertEqual(partials, ["let ", "let a", "let a = 1", "let a = 1\n"])
+    }
+
+    func test_streamCompletion_finalTextMatchesCollectCompletion() async throws {
+        let parts = ["fo", "o\n", "bar\n", "\n", "dropped"]
+        let streamed = try await feedCompletion(
+            from: tokens(parts),
+            stopWords: ["\n\n"],
+            limiter: StreamLineLimiter(lineLimit: 0, strategy: NeverStreamStopStrategy())
+        ) { _ in }
+        let collected = try await collectCompletion(
+            from: tokens(parts),
+            stopWords: ["\n\n"],
+            limiter: StreamLineLimiter(lineLimit: 0, strategy: NeverStreamStopStrategy())
+        )
+        XCTAssertEqual(streamed, "foo\nbar")
+        XCTAssertEqual(streamed, collected)
+    }
+
+    func test_streamCompletion_stopsWhereTheLimiterStops() async throws {
+        let limiter = StreamLineLimiter(lineLimit: 1, strategy: DefaultStreamStopStrategy())
+        var partials = [String]()
+        let result = try await feedCompletion(
+            from: tokens(["a\n", "b\n", "c"]),
+            stopWords: [],
+            limiter: limiter
+        ) { partials.append($0) }
+
+        XCTAssertEqual(result, "a\n")
+        XCTAssertEqual(partials, [])
+    }
+
+    func test_streamingEntryPoint_yieldsPartialsThenTheFinalText() async throws {
+        let service = ErasedCompletionService(FakeService(parts: ["x", "y", ""]))
+        var elements = [String]()
+        for try await element in service.streamCompletion(
+            FakePrompt(stopWords: [""]),
+            streamStopStrategy: NeverStreamStopStrategy()
+        ) {
+            elements.append(element)
+        }
+        XCTAssertEqual(elements, ["x", "xy", "xy"])
+    }
+
+    func test_erasedService_propagatesErrors() async {
+        struct Boom: Error {}
+        let service = ErasedCompletionService(FakeService(parts: ["x"], failure: Boom()))
+        var elements = [String]()
+        do {
+            for try await element in service.streamCompletion(
+                FakePrompt(stopWords: []),
+                streamStopStrategy: NeverStreamStopStrategy()
+            ) {
+                elements.append(element)
+            }
+            XCTFail("expected the error to propagate")
+        } catch {
+            XCTAssertTrue(error is Boom)
+        }
+        XCTAssertEqual(elements, ["x"])
+
+        do {
+            _ = try await service.getCompletions(
+                FakePrompt(stopWords: []),
+                streamStopStrategy: NeverStreamStopStrategy(),
+                count: 1
+            )
+            XCTFail("expected the error to propagate")
+        } catch {
+            XCTAssertTrue(error is Boom)
+        }
+    }
+}
+
+private struct FakeService: CodeCompletionServiceType {
+    let parts: [String]
+    var failure: Error? = nil
+
+    func getCompletion(_ request: PromptStrategy) async throws -> AsyncThrowingStream {
+        AsyncThrowingStream { continuation in
+            for part in parts { continuation.yield(part) }
+            if let failure {
+                continuation.finish(throwing: failure)
+            } else {
+                continuation.finish()
+            }
+        }
+    }
+}
+
+private struct FakePrompt: PromptStrategy {
+    var systemPrompt: String = ""
+    var prefix: [String] = []
+    var suffix: [String] = []
+    var relevantCodeSnippets: [RelevantCodeSnippet] = []
+    var stopWords: [String]
+    var language: CodeLanguage? = nil
+
+    func createPrompt(
+        truncatedPrefix: [String],
+        truncatedSuffix: [String],
+        includedSnippets: [RelevantCodeSnippet]
+    ) -> [PromptMessage] {
+        []
+    }
+}
diff --git a/Tool/Tests/SuggestionProviderTests/NeighboringSnippetRetrieverTests.swift b/Tool/Tests/SuggestionProviderTests/NeighboringSnippetRetrieverTests.swift
new file mode 100644
index 00000000..9a190f3e
--- /dev/null
+++ b/Tool/Tests/SuggestionProviderTests/NeighboringSnippetRetrieverTests.swift
@@ -0,0 +1,154 @@
+import Foundation
+import SuggestionBasic
+import XCTest
+
+@testable import SuggestionProvider
+
+final class CodeTokenizerTests: XCTestCase {
+    func test_splits_on_punctuation_and_lowercases() {
+        let tokens = CodeTokenizer.tokens(in: ["let workoutSummary = WorkoutStore.shared"])
+        XCTAssertTrue(tokens.contains("workoutsummary"))
+        XCTAssertTrue(tokens.contains("workoutstore"))
+        XCTAssertTrue(tokens.contains("shared"))
+    }
+
+    func test_drops_stop_words_and_short_tokens() {
+        let tokens = CodeTokenizer.tokens(in: ["let a = self.id"])
+        XCTAssertFalse(tokens.contains("let"))
+        XCTAssertFalse(tokens.contains("self"))
+        XCTAssertFalse(tokens.contains("a"))
+        XCTAssertFalse(tokens.contains("id"))
+    }
+
+    func test_keeps_underscored_identifiers_whole() {
+        let tokens = CodeTokenizer.tokens(in: ["private var _cachedWorkout: Int"])
+        XCTAssertTrue(tokens.contains("_cachedworkout"))
+    }
+}
+
+final class NeighboringSnippetRetrieverTests: XCTestCase {
+    let options = NeighboringSnippetRetriever.Options(
+        referenceWindow: 40,
+        windowSize: 3,
+        windowStride: 3,
+        maxSnippets: 3,
+        maxSnippetsPerFile: 1,
+        minimumScore: 0.05
+    )
+
+    func makeQuery(_ lines: [String]) -> Set {
+        NeighboringSnippetRetriever.queryTokens(
+            currentLines: lines,
+            cursorPosition: .init(line: lines.count - 1, character: 0),
+            options: options
+        )
+    }
+
+    func test_index_covers_every_line() {
+        let source = NeighboringSnippetRetriever.index(
+            filePath: "A.swift",
+            lines: (0..<7).map { "line\($0)" },
+            options: options
+        )
+        XCTAssertEqual(source.windows.map(\.startLine), [0, 3, 6])
+        XCTAssertEqual(source.windows.last?.endLine, 7)
+    }
+
+    func test_jaccard_similarity() {
+        XCTAssertEqual(NeighboringSnippetRetriever.jaccardSimilarity(["a", "b"], ["a", "b"]), 1)
+        XCTAssertEqual(NeighboringSnippetRetriever.jaccardSimilarity(["a", "b"], ["c"]), 0)
+        XCTAssertEqual(
+            NeighboringSnippetRetriever.jaccardSimilarity(["a", "b"], ["b", "c"]),
+            1.0 / 3.0,
+            accuracy: 0.0001
+        )
+        XCTAssertEqual(NeighboringSnippetRetriever.jaccardSimilarity([], ["a"]), 0)
+    }
+
+    func test_picks_the_file_that_shares_identifiers() {
+        let related = NeighboringSnippetRetriever.index(
+            filePath: "WorkoutStore.swift",
+            lines: [
+                "final class WorkoutStore {",
+                "    func loadWorkoutSummary() -> WorkoutSummary { .init() }",
+                "}",
+            ],
+            options: options
+        )
+        let unrelated = NeighboringSnippetRetriever.index(
+            filePath: "NetworkClient.swift",
+            lines: [
+                "final class NetworkClient {",
+                "    func request(path: String) -> Data { .init() }",
+                "}",
+            ],
+            options: options
+        )
+
+        let snippets = NeighboringSnippetRetriever.retrieve(
+            query: makeQuery(["let summary = WorkoutStore.shared.loadWorkoutSummary()"]),
+            sources: [unrelated, related],
+            options: options
+        )
+
+        XCTAssertEqual(snippets.count, 1)
+        XCTAssertEqual(snippets.first?.filePath, "WorkoutStore.swift")
+        XCTAssertTrue(snippets.first?.content.hasPrefix("// Path: WorkoutStore.swift\n") ?? false)
+        XCTAssertTrue(snippets.first?.content.contains("loadWorkoutSummary") ?? false)
+    }
+
+    func test_minimum_score_drops_unrelated_files() {
+        let unrelated = NeighboringSnippetRetriever.index(
+            filePath: "NetworkClient.swift",
+            lines: ["final class NetworkClient {}"],
+            options: options
+        )
+        var strict = options
+        strict.minimumScore = 0.9
+        let snippets = NeighboringSnippetRetriever.retrieve(
+            query: makeQuery(["let summary = WorkoutStore.shared"]),
+            sources: [unrelated],
+            options: strict
+        )
+        XCTAssertTrue(snippets.isEmpty)
+    }
+
+    func test_respects_per_file_and_total_limits_and_orders_best_first() {
+        let lines = (0..<9).map { index in
+            index == 3 ? "let workoutSummary = 1" : "let unrelated\(index) = \(index)"
+        }
+        let source = NeighboringSnippetRetriever.index(
+            filePath: "A.swift",
+            lines: lines,
+            options: options
+        )
+        let other = NeighboringSnippetRetriever.index(
+            filePath: "B.swift",
+            lines: ["let workoutSummary = 2"],
+            options: options
+        )
+
+        let snippets = NeighboringSnippetRetriever.retrieve(
+            query: makeQuery(["let workoutSummary = 0"]),
+            sources: [source, other],
+            options: options
+        )
+
+        XCTAssertEqual(snippets.count, 2, "one window per file")
+        XCTAssertEqual(snippets.map(\.priority), [2, 1], "best first")
+        XCTAssertEqual(snippets.first?.filePath, "B.swift", "the tighter match wins")
+    }
+
+    func test_empty_query_returns_nothing() {
+        let source = NeighboringSnippetRetriever.index(
+            filePath: "A.swift",
+            lines: ["let workoutSummary = 1"],
+            options: options
+        )
+        XCTAssertTrue(NeighboringSnippetRetriever.retrieve(
+            query: [],
+            sources: [source],
+            options: options
+        ).isEmpty)
+    }
+}
diff --git a/Tool/Tests/SuggestionProviderTests/RecentEditSnippetTests.swift b/Tool/Tests/SuggestionProviderTests/RecentEditSnippetTests.swift
new file mode 100644
index 00000000..f51d6ed3
--- /dev/null
+++ b/Tool/Tests/SuggestionProviderTests/RecentEditSnippetTests.swift
@@ -0,0 +1,149 @@
+import Foundation
+import XCTest
+
+@testable import SuggestionProvider
+
+final class RecentEditDiffTests: XCTestCase {
+    func test_no_change_produces_no_hunk() {
+        XCTAssertNil(RecentEditDiff.hunk(
+            filePath: "A.swift",
+            before: ["a", "b"],
+            after: ["a", "b"]
+        ))
+    }
+
+    func test_insertion() {
+        let hunk = RecentEditDiff.hunk(
+            filePath: "A.swift",
+            before: ["a", "c"],
+            after: ["a", "b", "c"]
+        )
+        XCTAssertEqual(hunk?.startLine, 1)
+        XCTAssertEqual(hunk?.removed, [])
+        XCTAssertEqual(hunk?.inserted, ["b"])
+    }
+
+    func test_deletion() {
+        let hunk = RecentEditDiff.hunk(
+            filePath: "A.swift",
+            before: ["a", "b", "c"],
+            after: ["a", "c"]
+        )
+        XCTAssertEqual(hunk?.startLine, 1)
+        XCTAssertEqual(hunk?.removed, ["b"])
+        XCTAssertEqual(hunk?.inserted, [])
+    }
+
+    func test_replacement_keeps_only_the_changed_middle() {
+        let hunk = RecentEditDiff.hunk(
+            filePath: "A.swift",
+            before: ["head", "old1", "old2", "tail"],
+            after: ["head", "new1", "tail"]
+        )
+        XCTAssertEqual(hunk?.startLine, 1)
+        XCTAssertEqual(hunk?.removed, ["old1", "old2"])
+        XCTAssertEqual(hunk?.inserted, ["new1"])
+    }
+
+    func test_line_endings_are_normalized_before_comparing() {
+        XCTAssertNil(RecentEditDiff.hunk(
+            filePath: "A.swift",
+            before: ["a\n", "b\n"],
+            after: ["a", "b"]
+        ))
+
+        let hunk = RecentEditDiff.hunk(
+            filePath: "A.swift",
+            before: ["let a = 1\n"],
+            after: ["let a = 12\n"]
+        )
+        XCTAssertEqual(hunk?.removed, ["let a = 1"])
+        XCTAssertEqual(hunk?.inserted, ["let a = 12"])
+    }
+
+    func test_typing_at_the_end_of_a_file() {
+        let hunk = RecentEditDiff.hunk(
+            filePath: "A.swift",
+            before: ["a", ""],
+            after: ["a", "b"]
+        )
+        XCTAssertEqual(hunk?.startLine, 1)
+        XCTAssertEqual(hunk?.removed, [""])
+        XCTAssertEqual(hunk?.inserted, ["b"])
+    }
+}
+
+final class RecentEditSnippetBuilderTests: XCTestCase {
+    func makeHunk(_ path: String, inserted: [String]) -> RecentEditHunk {
+        .init(filePath: path, startLine: 0, removed: [], inserted: inserted)
+    }
+
+    func test_no_hunks_produces_no_snippet() {
+        XCTAssertNil(RecentEditSnippetBuilder.snippet(from: [], priority: 1))
+    }
+
+    func test_every_line_is_commented_out() {
+        let snippet = RecentEditSnippetBuilder.snippet(
+            from: [.init(
+                filePath: "A.swift",
+                startLine: 3,
+                removed: ["var a: Int"],
+                inserted: ["var a: Int = 0"]
+            )],
+            priority: 4
+        )
+        let lines = snippet?.content.components(separatedBy: "\n") ?? []
+        XCTAssertFalse(lines.isEmpty)
+        for line in lines {
+            XCTAssertTrue(line.hasPrefix("//"), "unsafe line in a code prompt: \(line)")
+        }
+        XCTAssertTrue(lines.contains("// --- A.swift"))
+        XCTAssertTrue(lines.contains("// - var a: Int"))
+        XCTAssertTrue(lines.contains("// + var a: Int = 0"))
+        XCTAssertEqual(snippet?.priority, 4)
+    }
+
+    func test_uses_the_language_comment_prefix() {
+        let snippet = RecentEditSnippetBuilder.snippet(
+            from: [makeHunk("a.py", inserted: ["x = 1"])],
+            commentPrefix: CodeCommentStyle.prefix(forFileExtension: "py"),
+            priority: 1
+        )
+        XCTAssertTrue(snippet?.content.contains("# + x = 1") ?? false)
+    }
+
+    func test_keeps_only_the_most_recent_hunks() {
+        let snippet = RecentEditSnippetBuilder.snippet(
+            from: [
+                makeHunk("Oldest.swift", inserted: ["a"]),
+                makeHunk("Middle.swift", inserted: ["b"]),
+                makeHunk("Newest.swift", inserted: ["c"]),
+            ],
+            priority: 1,
+            maxHunks: 2
+        )
+        let content = snippet?.content ?? ""
+        XCTAssertFalse(content.contains("Oldest.swift"))
+        XCTAssertTrue(content.contains("Middle.swift"))
+        XCTAssertTrue(content.contains("Newest.swift"))
+    }
+
+    func test_truncates_long_hunks() {
+        let snippet = RecentEditSnippetBuilder.snippet(
+            from: [makeHunk("A.swift", inserted: (0..<10).map { "line\($0)" })],
+            priority: 1,
+            maxLinesPerHunk: 2
+        )
+        let content = snippet?.content ?? ""
+        XCTAssertTrue(content.contains("// + line0"))
+        XCTAssertTrue(content.contains("// + line1"))
+        XCTAssertFalse(content.contains("// + line2"))
+        XCTAssertTrue(content.contains("8 more lines"))
+    }
+
+    func test_comment_style_defaults_to_double_slash() {
+        XCTAssertEqual(CodeCommentStyle.prefix(forFileExtension: "swift"), "//")
+        XCTAssertEqual(CodeCommentStyle.prefix(forFileExtension: "SQL"), "--")
+        XCTAssertEqual(CodeCommentStyle.prefix(forFileExtension: "unknown"), "//")
+    }
+}

From 4b60f8aa4e6782f8e2f81108fd0bc7ff26892ff2 Mon Sep 17 00:00:00 2001
From: Skyler Liu <9743878+JayDaniel@users.noreply.github.com>
Date: Wed, 9 Sep 2026 01:53:44 -0700
Subject: [PATCH 16/16] Localize the UI and require Apple Silicon

SwiftUI resolves a Text literal against Bundle.main, so localizations
shipped inside an SPM package never apply. Putting the catalog in the
three executable bundles instead localizes roughly 550 call sites
across the Core and Tool packages without touching any of them.

- Add Localization/Localizable.xcstrings with 450 entries in en,
  zh-Hans and zh-Hant, and attach it to the host app, the extension
  service and the editor extension
- Narrow the title of SettingsDivider, SubSection, SidebarTabView,
  TabContainer and the pickers from String to LocalizedStringKey, so
  that their call sites need no edit
- Look toast text up as a LocalizedStringKey, and run the status bar
  menu and the Xcode editor command names through NSLocalizedString
- Add AppLanguage and the appLanguage preference, offer a language
  picker in General settings, and have all three processes write
  AppleLanguages at launch
- Raise the deployment target to macOS 15.6, pin ARCHS to arm64 in
  both xcconfigs and add EXCLUDED_ARCHS = x86_64, dropping the
  target-level ARCHS overrides
- Bump the version to 0.40.0 (505)

A target-level ARCHS overrides the project xcconfig, and Xcode writes
one back whenever a build setting is touched, so EXCLUDED_ARCHS is
there as a backstop and archives no longer carry an x86_64 slice.

Claude-Session: https://claude.ai/code/session_01KFzJ8F9F96FQDwBfRej3ad
---
 Config.debug.xcconfig                         |     9 +
 Config.xcconfig                               |     9 +
 Copilot for Xcode.xcodeproj/project.pbxproj   |    42 +-
 Copilot for Xcode/App.swift                   |     5 +
 .../APIKeyManagement/APIKeyPicker.swift       |     2 +-
 .../ChatModelEditView.swift                   |     2 +-
 .../CompletionModelEditView.swift             |     4 +-
 .../FIMModelEditView.swift                    |     4 +-
 .../SharedModelFields.swift                   |     6 +-
 .../EmbeddingModelEditView.swift              |     2 +-
 .../GitHubCopilotModelPicker.swift            |     4 +-
 .../SharedModelManagement/BaseURLPicker.swift |     4 +-
 .../Sources/HostApp/FeatureSettingsView.swift |     4 +-
 Core/Sources/HostApp/GeneralView.swift        |    28 +-
 Core/Sources/HostApp/SidebarTabView.swift     |    12 +-
 Core/Sources/HostApp/TabContainer.swift       |    10 +-
 EditorExtension/SourceEditorExtension.swift   |     5 +-
 ExtensionService/AppDelegate+Menu.swift       |    31 +-
 ExtensionService/AppDelegate.swift            |     4 +
 Localization/Localizable.xcstrings            | 10356 ++++++++++++++++
 README.md                                     |     4 +-
 Tool/Sources/Preferences/AppLanguage.swift    |    49 +
 Tool/Sources/Preferences/Keys.swift           |     8 +
 .../SharedUIComponents/SettingsDivider.swift  |     2 +-
 .../SharedUIComponents/SubSection.swift       |     4 +-
 Tool/Sources/Toast/Toast.swift                |     4 +-
 Version.xcconfig                              |     4 +-
 27 files changed, 10550 insertions(+), 68 deletions(-)
 create mode 100644 Localization/Localizable.xcstrings
 create mode 100644 Tool/Sources/Preferences/AppLanguage.swift

diff --git a/Config.debug.xcconfig b/Config.debug.xcconfig
index 33fde88c..146b1ac0 100644
--- a/Config.debug.xcconfig
+++ b/Config.debug.xcconfig
@@ -1,6 +1,15 @@
 #include "Version.xcconfig"
 SLASH = /
 
+// Apple Silicon only: no Intel slice is built, so no target can fall back to
+// ARCHS_STANDARD and quietly reintroduce x86_64.
+ARCHS = arm64
+
+// Belt and braces: Xcode re-materializes ARCHS = $(ARCHS_STANDARD) at target level whenever the
+// Architectures row is touched in the UI, which silently overrides the line above. EXCLUDED_ARCHS
+// is subtracted from whatever ARCHS ends up being, so the Intel slice stays out either way.
+EXCLUDED_ARCHS = x86_64
+
 HOST_APP_NAME = Copilot for Xcode Dev
 BUNDLE_IDENTIFIER_BASE = dev.com.intii.CopilotForXcode
 SPARKLE_FEED_URL = http:$(SLASH)$(SLASH)127.0.0.1:9433/appcast.xml
diff --git a/Config.xcconfig b/Config.xcconfig
index 26b89e0d..de738d4f 100644
--- a/Config.xcconfig
+++ b/Config.xcconfig
@@ -1,6 +1,15 @@
 #include "Version.xcconfig"
 SLASH = /
 
+// Apple Silicon only: no Intel slice is built, so no target can fall back to
+// ARCHS_STANDARD and quietly reintroduce x86_64.
+ARCHS = arm64
+
+// Belt and braces: Xcode re-materializes ARCHS = $(ARCHS_STANDARD) at target level whenever the
+// Architectures row is touched in the UI, which silently overrides the line above. EXCLUDED_ARCHS
+// is subtracted from whatever ARCHS ends up being, so the Intel slice stays out either way.
+EXCLUDED_ARCHS = x86_64
+
 HOST_APP_NAME = Copilot for Xcode
 BUNDLE_IDENTIFIER_BASE = com.intii.CopilotForXcode
 SPARKLE_FEED_URL = https:$(SLASH)$(SLASH)copilotforxcode.intii.com/appcast.xml
diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj
index 3ed6baa6..14637229 100644
--- a/Copilot for Xcode.xcodeproj/project.pbxproj	
+++ b/Copilot for Xcode.xcodeproj/project.pbxproj	
@@ -7,6 +7,9 @@
 	objects = {
 
 /* Begin PBXBuildFile section */
+		C8A1180029000000000000A1 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = C8A1180029000000000000A0 /* Localizable.xcstrings */; };
+		C8A1180029000000000000A2 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = C8A1180029000000000000A0 /* Localizable.xcstrings */; };
+		C8A1180029000000000000A3 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = C8A1180029000000000000A0 /* Localizable.xcstrings */; };
 		C8009BFF2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8009BFE2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift */; };
 		C8009C032941C576007AA7E8 /* RealtimeSuggestionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8009C022941C576007AA7E8 /* RealtimeSuggestionCommand.swift */; };
 		C800DBB1294C624D00B04CAC /* PrefetchSuggestionsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C800DBB0294C624D00B04CAC /* PrefetchSuggestionsCommand.swift */; };
@@ -35,7 +38,6 @@
 		C8738B6B2BE4D56F00609E7F /* ServiceDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8738B6A2BE4D56F00609E7F /* ServiceDelegate.swift */; };
 		C8738B6F2BE4F7A600609E7F /* XPCShared in Frameworks */ = {isa = PBXBuildFile; productRef = C8738B6E2BE4F7A600609E7F /* XPCShared */; };
 		C8738B712BE4F8B700609E7F /* XPCController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8738B702BE4F8B700609E7F /* XPCController.swift */; };
-		C8F4D1026F0AF0FD00A4C002 /* ExecutableFingerprint.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8F4D1016F0AF0FD00A4C001 /* ExecutableFingerprint.swift */; };
 		C8738B7B2BE5363800609E7F /* SandboxedClientTesterApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8738B7A2BE5363800609E7F /* SandboxedClientTesterApp.swift */; };
 		C8738B7D2BE5363800609E7F /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8738B7C2BE5363800609E7F /* ContentView.swift */; };
 		C8738B7F2BE5363900609E7F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C8738B7E2BE5363900609E7F /* Assets.xcassets */; };
@@ -56,6 +58,7 @@
 		C8C8B60929AFA35F00034BEE /* CopilotForXcodeExtensionService.app in Embed XPCService */ = {isa = PBXBuildFile; fileRef = C861E60E2994F6070056CB02 /* CopilotForXcodeExtensionService.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
 		C8DCF00029CE11D500FDDDD7 /* OpenChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8DCEFFF29CE11D500FDDDD7 /* OpenChat.swift */; };
 		C8DD9CB12BC673F80036641C /* CloseIdleTabsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8DD9CB02BC673F80036641C /* CloseIdleTabsCommand.swift */; };
+		C8F4D1026F0AF0FD00A4C002 /* ExecutableFingerprint.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8F4D1016F0AF0FD00A4C001 /* ExecutableFingerprint.swift */; };
 /* End PBXBuildFile section */
 
 /* Begin PBXContainerItemProxy section */
@@ -178,6 +181,7 @@
 /* End PBXCopyFilesBuildPhase section */
 
 /* Begin PBXFileReference section */
+		C8A1180029000000000000A0 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; name = Localizable.xcstrings; path = Localization/Localizable.xcstrings; sourceTree = ""; };
 		C8009BFE2941C551007AA7E8 /* ToggleRealtimeSuggestionsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleRealtimeSuggestionsCommand.swift; sourceTree = ""; };
 		C8009C022941C576007AA7E8 /* RealtimeSuggestionCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealtimeSuggestionCommand.swift; sourceTree = ""; };
 		C800DBB0294C624D00B04CAC /* PrefetchSuggestionsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefetchSuggestionsCommand.swift; sourceTree = ""; };
@@ -220,7 +224,6 @@
 		C8738B6A2BE4D56F00609E7F /* ServiceDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceDelegate.swift; sourceTree = ""; };
 		C8738B6D2BE4F3E800609E7F /* bridgeLaunchAgent.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = bridgeLaunchAgent.plist; sourceTree = ""; };
 		C8738B702BE4F8B700609E7F /* XPCController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XPCController.swift; sourceTree = ""; };
-		C8F4D1016F0AF0FD00A4C001 /* ExecutableFingerprint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecutableFingerprint.swift; sourceTree = ""; };
 		C8738B782BE5363800609E7F /* SandboxedClientTester.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SandboxedClientTester.app; sourceTree = BUILT_PRODUCTS_DIR; };
 		C8738B7A2BE5363800609E7F /* SandboxedClientTesterApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxedClientTesterApp.swift; sourceTree = ""; };
 		C8738B7C2BE5363800609E7F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
@@ -242,6 +245,7 @@
 		C8DCEFFF29CE11D500FDDDD7 /* OpenChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenChat.swift; sourceTree = ""; };
 		C8DD9CB02BC673F80036641C /* CloseIdleTabsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseIdleTabsCommand.swift; sourceTree = ""; };
 		C8F103292A7A365000D28F4F /* launchAgent.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = launchAgent.plist; sourceTree = ""; };
+		C8F4D1016F0AF0FD00A4C001 /* ExecutableFingerprint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecutableFingerprint.swift; sourceTree = ""; };
 /* End PBXFileReference section */
 
 /* Begin PBXFrameworksBuildPhase section */
@@ -344,6 +348,7 @@
 				C81458AD293A009600135263 /* Config.xcconfig */,
 				C81458AE293A009800135263 /* Config.debug.xcconfig */,
 				C8CD828229B88006008D044D /* TestPlan.xctestplan */,
+				C8A1180029000000000000A0 /* Localizable.xcstrings */,
 				C828B27D2B1F241500E7612A /* ExtensionPoint.appextensionpoint */,
 				C8BE64922EB9B42E00EDB2D7 /* OverlayWindow */,
 				C84FD9D72CC671C600BE5093 /* ChatPlugins */,
@@ -618,6 +623,8 @@
 			knownRegions = (
 				en,
 				Base,
+				"zh-Hans",
+				"zh-Hant",
 			);
 			mainGroup = C8189B0D2938972F00C9DCDA;
 			packageReferences = (
@@ -643,6 +650,7 @@
 			isa = PBXResourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				C8A1180029000000000000A3 /* Localizable.xcstrings in Resources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
@@ -650,6 +658,7 @@
 			isa = PBXResourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				C8A1180029000000000000A1 /* Localizable.xcstrings in Resources */,
 				C8189B212938973000C9DCDA /* Preview Assets.xcassets in Resources */,
 				C8189B1E2938973000C9DCDA /* Assets.xcassets in Resources */,
 			);
@@ -659,6 +668,7 @@
 			isa = PBXResourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				C8A1180029000000000000A2 /* Localizable.xcstrings in Resources */,
 				C861E6152994F6080056CB02 /* Assets.xcassets in Resources */,
 				C81291D72994FE6900196E12 /* Main.storyboard in Resources */,
 			);
@@ -790,7 +800,7 @@
 					"@executable_path/../Frameworks",
 					"@executable_path/../../../../Frameworks",
 				);
-				MACOSX_DEPLOYMENT_TARGET = 13.0;
+				MACOSX_DEPLOYMENT_TARGET = 15.6;
 				MARKETING_VERSION = "$(APP_VERSION)";
 				PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).EditorExtension";
 				PRODUCT_NAME = Copilot;
@@ -818,7 +828,7 @@
 					"@executable_path/../Frameworks",
 					"@executable_path/../../../../Frameworks",
 				);
-				MACOSX_DEPLOYMENT_TARGET = 13.0;
+				MACOSX_DEPLOYMENT_TARGET = 15.6;
 				MARKETING_VERSION = "$(APP_VERSION)";
 				PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).EditorExtension";
 				PRODUCT_NAME = Copilot;
@@ -881,7 +891,7 @@
 				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
 				GCC_WARN_UNUSED_FUNCTION = YES;
 				GCC_WARN_UNUSED_VARIABLE = YES;
-				MACOSX_DEPLOYMENT_TARGET = 12.0;
+				MACOSX_DEPLOYMENT_TARGET = 15.6;
 				MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
 				MTL_FAST_MATH = YES;
 				ONLY_ACTIVE_ARCH = YES;
@@ -938,7 +948,7 @@
 				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
 				GCC_WARN_UNUSED_FUNCTION = YES;
 				GCC_WARN_UNUSED_VARIABLE = YES;
-				MACOSX_DEPLOYMENT_TARGET = 12.0;
+				MACOSX_DEPLOYMENT_TARGET = 15.6;
 				MTL_ENABLE_DEBUG_INFO = NO;
 				MTL_FAST_MATH = YES;
 				SDKROOT = macosx;
@@ -971,7 +981,7 @@
 					"$(inherited)",
 					"@executable_path/../Frameworks",
 				);
-				MACOSX_DEPLOYMENT_TARGET = 13.0;
+				MACOSX_DEPLOYMENT_TARGET = 15.6;
 				MARKETING_VERSION = "$(APP_VERSION)";
 				PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE)";
 				PRODUCT_MODULE_NAME = Copilot_for_Xcode;
@@ -1005,7 +1015,7 @@
 					"$(inherited)",
 					"@executable_path/../Frameworks",
 				);
-				MACOSX_DEPLOYMENT_TARGET = 13.0;
+				MACOSX_DEPLOYMENT_TARGET = 15.6;
 				MARKETING_VERSION = "$(APP_VERSION)";
 				PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE)";
 				PRODUCT_NAME = "$(HOST_APP_NAME)";
@@ -1021,7 +1031,7 @@
 				DEAD_CODE_STRIPPING = YES;
 				DEVELOPMENT_TEAM = "$(inherited)";
 				ENABLE_HARDENED_RUNTIME = YES;
-				MACOSX_DEPLOYMENT_TARGET = 13.0;
+				MACOSX_DEPLOYMENT_TARGET = 15.6;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
 				SWIFT_VERSION = 5.0;
@@ -1035,7 +1045,7 @@
 				DEAD_CODE_STRIPPING = YES;
 				DEVELOPMENT_TEAM = "$(inherited)";
 				ENABLE_HARDENED_RUNTIME = YES;
-				MACOSX_DEPLOYMENT_TARGET = 13.0;
+				MACOSX_DEPLOYMENT_TARGET = 15.6;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
 				SWIFT_VERSION = 5.0;
@@ -1065,7 +1075,7 @@
 					"$(inherited)",
 					"@executable_path/../Frameworks",
 				);
-				MACOSX_DEPLOYMENT_TARGET = 13.0;
+				MACOSX_DEPLOYMENT_TARGET = 15.6;
 				MARKETING_VERSION = "$(APP_VERSION)";
 				PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).ExtensionService";
 				PRODUCT_NAME = "$(EXTENSION_SERVICE_NAME)";
@@ -1098,7 +1108,7 @@
 					"$(inherited)",
 					"@executable_path/../Frameworks",
 				);
-				MACOSX_DEPLOYMENT_TARGET = 13.0;
+				MACOSX_DEPLOYMENT_TARGET = 15.6;
 				MARKETING_VERSION = "$(APP_VERSION)";
 				PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).ExtensionService";
 				PRODUCT_NAME = "$(EXTENSION_SERVICE_NAME)";
@@ -1118,7 +1128,7 @@
 				ENABLE_USER_SCRIPT_SANDBOXING = YES;
 				GCC_C_LANGUAGE_STANDARD = gnu17;
 				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
-				MACOSX_DEPLOYMENT_TARGET = 13.0;
+				MACOSX_DEPLOYMENT_TARGET = 15.6;
 				PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).CommunicationBridge";
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -1137,7 +1147,7 @@
 				ENABLE_USER_SCRIPT_SANDBOXING = YES;
 				GCC_C_LANGUAGE_STANDARD = gnu17;
 				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
-				MACOSX_DEPLOYMENT_TARGET = 13.0;
+				MACOSX_DEPLOYMENT_TARGET = 15.6;
 				PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER_BASE).CommunicationBridge";
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -1169,7 +1179,7 @@
 					"@executable_path/../Frameworks",
 				);
 				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
-				MACOSX_DEPLOYMENT_TARGET = 14.2;
+				MACOSX_DEPLOYMENT_TARGET = 15.6;
 				MARKETING_VERSION = 1.0;
 				PRODUCT_BUNDLE_IDENTIFIER = com.intii.CopilotForXcode.SandboxedClientTester;
 				PRODUCT_NAME = "$(TARGET_NAME)";
@@ -1203,7 +1213,7 @@
 					"@executable_path/../Frameworks",
 				);
 				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
-				MACOSX_DEPLOYMENT_TARGET = 14.2;
+				MACOSX_DEPLOYMENT_TARGET = 15.6;
 				MARKETING_VERSION = 1.0;
 				PRODUCT_BUNDLE_IDENTIFIER = com.intii.CopilotForXcode.SandboxedClientTester;
 				PRODUCT_NAME = "$(TARGET_NAME)";
diff --git a/Copilot for Xcode/App.swift b/Copilot for Xcode/App.swift
index 99a0a044..736d9b8b 100644
--- a/Copilot for Xcode/App.swift	
+++ b/Copilot for Xcode/App.swift	
@@ -1,6 +1,7 @@
 import Client
 import HostApp
 import LaunchAgentManager
+import Preferences
 import SwiftUI
 import UpdateChecker
 import XPCShared
@@ -24,6 +25,10 @@ let updateCheckerDelegate = TheUpdateCheckerDelegate()
 
 @main
 struct CopilotForXcodeApp: App {
+    init() {
+        AppLanguage.applyPreferredLanguage()
+    }
+
     var body: some Scene {
         WindowGroup {
             TabContainer()
diff --git a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift
index 258ce38c..dea50bcd 100644
--- a/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift
+++ b/Core/Sources/HostApp/AccountSettings/APIKeyManagement/APIKeyPicker.swift
@@ -3,7 +3,7 @@ import SwiftUI
 
 struct APIKeyPicker: View {
     @Perception.Bindable var store: StoreOf
-    var title: String = "API Key"
+    var title: LocalizedStringKey = "API Key"
 
     var body: some View {
         WithPerceptionTracking {
diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift
index d16b7556..373ed593 100644
--- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift
+++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift
@@ -151,7 +151,7 @@ struct ChatModelEditView: View {
 
     struct BaseURLTextField: View {
         let store: StoreOf
-        var title: String = "Base URL"
+        var title: LocalizedStringKey = "Base URL"
         let prompt: Text?
         @ViewBuilder var trailingContent: () -> V
 
diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CompletionModelEditView.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CompletionModelEditView.swift
index df30963c..90493387 100644
--- a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CompletionModelEditView.swift
+++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/CompletionModelEditView.swift
@@ -89,7 +89,7 @@ struct CompletionModelEditView: View {
     }
 
     func baseURLTextField(
-        title: String = "Base URL",
+        title: LocalizedStringKey = "Base URL",
         prompt: Text?,
         @ViewBuilder trailingContent: @escaping () -> V
     ) -> some View {
@@ -105,7 +105,7 @@ struct CompletionModelEditView: View {
     }
 
     func baseURLTextField(
-        title: String = "Base URL",
+        title: LocalizedStringKey = "Base URL",
         prompt: Text?
     ) -> some View {
         baseURLTextField(title: title, prompt: prompt, trailingContent: { EmptyView() })
diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/FIMModelEditView.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/FIMModelEditView.swift
index 2eb75680..3b39147a 100644
--- a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/FIMModelEditView.swift
+++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/FIMModelEditView.swift
@@ -85,7 +85,7 @@ struct FIMModelEditView: View {
     }
 
     func baseURLTextField(
-        title: String = "Base URL",
+        title: LocalizedStringKey = "Base URL",
         prompt: Text?,
         @ViewBuilder trailingContent: @escaping () -> V
     ) -> some View {
@@ -101,7 +101,7 @@ struct FIMModelEditView: View {
     }
 
     func baseURLTextField(
-        title: String = "Base URL",
+        title: LocalizedStringKey = "Base URL",
         prompt: Text?
     ) -> some View {
         baseURLTextField(title: title, prompt: prompt, trailingContent: { EmptyView() })
diff --git a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/SharedModelFields.swift b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/SharedModelFields.swift
index f8df25af..8fa1a668 100644
--- a/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/SharedModelFields.swift
+++ b/Core/Sources/HostApp/AccountSettings/CustomModelSuggestion/SharedModelFields.swift
@@ -49,7 +49,7 @@ struct MaxTokensTextField: View {
 }
 
 struct BaseURLTextField: View {
-    var title: String = "Base URL"
+    var title: LocalizedStringKey = "Base URL"
     var prompt: Text?
     let store: StoreOf
     @ViewBuilder var trailingContent: () -> TrailingContent
@@ -66,7 +66,7 @@ struct BaseURLTextField: View {
 
 extension BaseURLTextField where TrailingContent == EmptyView {
     init(
-        title: String = "Base URL",
+        title: LocalizedStringKey = "Base URL",
         prompt: Text?,
         store: StoreOf
     ) {
@@ -76,7 +76,7 @@ extension BaseURLTextField where TrailingContent == EmptyView {
 
 struct ApiKeyNamePicker: View {
     let store: StoreOf
-    var title: String = "API Key"
+    var title: LocalizedStringKey = "API Key"
 
     var body: some View {
         APIKeyPicker(store: store, title: title)
diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift
index 46f4effd..720bd45a 100644
--- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift
+++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift
@@ -119,7 +119,7 @@ struct EmbeddingModelEditView: View {
 
     struct BaseURLTextField: View {
         let store: StoreOf
-        var title: String = "Base URL"
+        var title: LocalizedStringKey = "Base URL"
         let prompt: Text?
         @ViewBuilder var trailingContent: () -> V
 
diff --git a/Core/Sources/HostApp/AccountSettings/GitHubCopilotModelPicker.swift b/Core/Sources/HostApp/AccountSettings/GitHubCopilotModelPicker.swift
index 9f4b0d8d..257a31bc 100644
--- a/Core/Sources/HostApp/AccountSettings/GitHubCopilotModelPicker.swift
+++ b/Core/Sources/HostApp/AccountSettings/GitHubCopilotModelPicker.swift
@@ -30,13 +30,13 @@ public struct GitHubCopilotModelPicker: View {
         }
     }
 
-    let title: String
+    let title: LocalizedStringKey
     let hasDefaultModel: Bool
     @Binding var gitHubCopilotModelId: String
     @State var viewModel: ViewModel
 
     init(
-        title: String,
+        title: LocalizedStringKey,
         hasDefaultModel: Bool = true,
         gitHubCopilotModelId: Binding
     ) {
diff --git a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift
index 9456946e..00a7803c 100644
--- a/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift
+++ b/Core/Sources/HostApp/AccountSettings/SharedModelManagement/BaseURLPicker.swift
@@ -2,7 +2,7 @@ import ComposableArchitecture
 import SwiftUI
 
 struct BaseURLPicker: View {
-    let title: String
+    let title: LocalizedStringKey
     let prompt: Text?
     @Perception.Bindable var store: StoreOf
     @ViewBuilder let trailingContent: () -> TrailingContent
@@ -45,7 +45,7 @@ struct BaseURLPicker: View {
 
 extension BaseURLPicker where TrailingContent == EmptyView {
     init(
-        title: String,
+        title: LocalizedStringKey,
         prompt: Text? = nil,
         store: StoreOf
     ) {
diff --git a/Core/Sources/HostApp/FeatureSettingsView.swift b/Core/Sources/HostApp/FeatureSettingsView.swift
index e8c1e38f..5dd93597 100644
--- a/Core/Sources/HostApp/FeatureSettingsView.swift
+++ b/Core/Sources/HostApp/FeatureSettingsView.swift
@@ -52,8 +52,8 @@ struct FeatureSettingsView: View {
                 }
                 .sidebarItem(
                     tag: 4 + index,
-                    title: tab.title,
-                    subtitle: tab.description,
+                    title: LocalizedStringKey(tab.title),
+                    subtitle: LocalizedStringKey(tab.description),
                     image: tab.image
                 )
             }
diff --git a/Core/Sources/HostApp/GeneralView.swift b/Core/Sources/HostApp/GeneralView.swift
index b69c0127..feebf7ec 100644
--- a/Core/Sources/HostApp/GeneralView.swift
+++ b/Core/Sources/HostApp/GeneralView.swift
@@ -5,6 +5,7 @@ import LaunchAgentManager
 import Preferences
 import SharedUIComponents
 import SwiftUI
+import Toast
 
 struct GeneralView: View {
     let store: StoreOf
@@ -101,12 +102,14 @@ struct ExtensionServiceView: View {
     var body: some View {
         WithPerceptionTracking {
             VStack(alignment: .leading) {
-                Text("Extension Service Version: \(store.xpcServiceVersion ?? "Loading..")")
+                Text("Extension Service Version: \(store.xpcServiceVersion ?? String(localized: "Loading.."))")
 
                 let grantedStatus: String = {
                     guard let granted = store.isAccessibilityPermissionGranted
-                    else { return "Loading.." }
-                    return granted ? "Granted" : "Not Granted"
+                    else { return String(localized: "Loading..") }
+                    return granted
+                        ? String(localized: "Granted")
+                        : String(localized: "Not Granted")
                 }()
                 Text("Accessibility Permission: \(grantedStatus)")
 
@@ -200,14 +203,33 @@ struct GeneralSettingsView: View {
         var showHideWidgetShortcutGlobally
         @AppStorage(\.installBetaBuilds)
         var installBetaBuilds
+        @AppStorage(\.appLanguage)
+        var appLanguage
     }
 
     @StateObject var settings = Settings()
     @Environment(\.updateChecker) var updateChecker
+    @Environment(\.toast) var toast
     @State var automaticallyCheckForUpdate: Bool?
 
     var body: some View {
         Form {
+            Picker(selection: $settings.appLanguage) {
+                ForEach(AppLanguage.allCases, id: \.rawValue) { language in
+                    Text(language.displayName).tag(language.rawValue)
+                }
+            } label: {
+                Text("Language")
+            }
+            .onChange(of: settings.appLanguage) { _ in
+                // The running processes keep the localization they resolved at launch.
+                AppLanguage.applyPreferredLanguage()
+                toast(
+                    String(localized: "Restart the app and the extension service to switch language."),
+                    .info
+                )
+            }
+
             Toggle(isOn: $settings.quitXPCServiceOnXcodeAndAppQuit) {
                 Text("Quit service when Xcode and host app are terminated")
             }
diff --git a/Core/Sources/HostApp/SidebarTabView.swift b/Core/Sources/HostApp/SidebarTabView.swift
index ae5b009e..5a6f842b 100644
--- a/Core/Sources/HostApp/SidebarTabView.swift
+++ b/Core/Sources/HostApp/SidebarTabView.swift
@@ -3,8 +3,8 @@ import SwiftUI
 private struct SidebarItem: Identifiable, Equatable {
     var id: Int { tag }
     var tag: Int
-    var title: String
-    var subtitle: String? = nil
+    var title: LocalizedStringKey
+    var subtitle: LocalizedStringKey? = nil
     var image: String? = nil
 }
 
@@ -29,8 +29,8 @@ private extension EnvironmentValues {
 private struct SidebarTabViewWrapper: View {
     @Environment(\.sidebarTabTag) var sidebarTabTag
     var tag: Int
-    var title: String
-    var subtitle: String? = nil
+    var title: LocalizedStringKey
+    var subtitle: LocalizedStringKey? = nil
     var image: String? = nil
     var content: () -> Content
 
@@ -52,8 +52,8 @@ private struct SidebarTabViewWrapper: View {
 extension View {
     func sidebarItem(
         tag: Int,
-        title: String,
-        subtitle: String? = nil,
+        title: LocalizedStringKey,
+        subtitle: LocalizedStringKey? = nil,
         image: String? = nil
     ) -> some View {
         SidebarTabViewWrapper(
diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift
index 8616b5af..ade8d803 100644
--- a/Core/Sources/HostApp/TabContainer.swift
+++ b/Core/Sources/HostApp/TabContainer.swift
@@ -65,7 +65,7 @@ public struct TabContainer: View {
                         let tab = externalTabContainer.tabs[index]
                         tab.viewBuilder().tabBarItem(
                             tag: 5 + index,
-                            title: tab.title,
+                            title: LocalizedStringKey(tab.title),
                             image: "plus.diamond"
                         )
                     }
@@ -116,7 +116,7 @@ struct TabBarButton: View {
     @Binding var currentTag: Int
     @State var isHovered = false
     var tag: Int
-    var title: String
+    var title: LocalizedStringKey
     var image: String
 
     var body: some View {
@@ -157,7 +157,7 @@ struct TabBarButton: View {
 private struct TabBarTabViewWrapper: View {
     @Environment(\.tabBarTabTag) var tabBarTabTag
     var tag: Int
-    var title: String
+    var title: LocalizedStringKey
     var image: String
     var content: () -> Content
 
@@ -179,7 +179,7 @@ private struct TabBarTabViewWrapper: View {
 private extension View {
     func tabBarItem(
         tag: Int,
-        title: String,
+        title: LocalizedStringKey,
         image: String
     ) -> some View {
         TabBarTabViewWrapper(
@@ -194,7 +194,7 @@ private extension View {
 private struct TabBarItem: Identifiable, Equatable {
     var id: Int { tag }
     var tag: Int
-    var title: String
+    var title: LocalizedStringKey
     var image: String
 }
 
diff --git a/EditorExtension/SourceEditorExtension.swift b/EditorExtension/SourceEditorExtension.swift
index f102f9d4..c260068f 100644
--- a/EditorExtension/SourceEditorExtension.swift
+++ b/EditorExtension/SourceEditorExtension.swift
@@ -52,6 +52,8 @@ class SourceEditorExtension: NSObject, XCSourceEditorExtension {
     }
 
     var commandDefinitions: [[XCSourceEditorCommandDefinitionKey: Any]] {
+        // Xcode can read this before `extensionDidFinishLaunching`, so pin the language here too.
+        AppLanguage.applyPreferredLanguage()
         return builtin + optional + custom + internalUse
     }
 
@@ -89,7 +91,8 @@ extension CommandType {
     func makeCommandDefinition() -> [XCSourceEditorCommandDefinitionKey: Any] {
         [.classNameKey: commandClassName,
          .identifierKey: identifierPrefix + identifier,
-         .nameKey: name]
+         // Command names show up in Xcode's Editor menu, so they follow the app's language.
+         .nameKey: NSLocalizedString(name, comment: "Xcode editor command name")]
     }
 }
 
diff --git a/ExtensionService/AppDelegate+Menu.swift b/ExtensionService/AppDelegate+Menu.swift
index 9107c97a..90728752 100644
--- a/ExtensionService/AppDelegate+Menu.swift
+++ b/ExtensionService/AppDelegate+Menu.swift
@@ -44,31 +44,31 @@ extension AppDelegate {
         )
 
         let checkForUpdate = NSMenuItem(
-            title: "Check for Updates",
+            title: NSLocalizedString("Check for Updates", comment: ""),
             action: #selector(checkForUpdate),
             keyEquivalent: ""
         )
 
         let openExtensionManager = NSMenuItem(
-            title: "Open Extension Manager",
+            title: NSLocalizedString("Open Extension Manager", comment: ""),
             action: #selector(openExtensionManager),
             keyEquivalent: ""
         )
 
         let openCopilotForXcode = NSMenuItem(
-            title: "Open \(hostAppName)",
+            title: String(format: NSLocalizedString("Open %@", comment: ""), hostAppName),
             action: #selector(openCopilotForXcode),
             keyEquivalent: ""
         )
 
         let openGlobalChat = NSMenuItem(
-            title: "Open Chat",
+            title: NSLocalizedString("Open Chat", comment: ""),
             action: #selector(openGlobalChat),
             keyEquivalent: ""
         )
 
         let xcodeInspectorDebug = NSMenuItem(
-            title: "Xcode Inspector Debug",
+            title: NSLocalizedString("Xcode Inspector Debug", comment: ""),
             action: nil,
             keyEquivalent: ""
         )
@@ -79,27 +79,27 @@ extension AppDelegate {
         xcodeInspectorDebug.isHidden = false
 
         let accessibilityAPIPermission = NSMenuItem(
-            title: "Accessibility API Permission: N/A",
+            title: NSLocalizedString("Accessibility API Permission: N/A", comment: ""),
             action: nil,
             keyEquivalent: ""
         )
         accessibilityAPIPermission.identifier = accessibilityAPIPermissionMenuItemIdentifier
 
         let quitItem = NSMenuItem(
-            title: "Quit",
+            title: NSLocalizedString("Quit", comment: ""),
             action: #selector(quit),
             keyEquivalent: ""
         )
         quitItem.target = self
 
         let reactivateObservationsItem = NSMenuItem(
-            title: "Reactivate Observations to Xcode",
+            title: NSLocalizedString("Reactivate Observations to Xcode", comment: ""),
             action: #selector(reactivateObservationsToXcode),
             keyEquivalent: ""
         )
         
         let resetWorkspacesItem = NSMenuItem(
-            title: "Reset workspaces",
+            title: NSLocalizedString("Reset workspaces", comment: ""),
             action: #selector(destroyWorkspacePool),
             keyEquivalent: ""
         )
@@ -139,8 +139,13 @@ extension AppDelegate: NSMenuDelegate {
                 item.identifier == accessibilityAPIPermissionMenuItemIdentifier
             }) {
                 AXIsProcessTrusted()
-                accessibilityAPIPermission.title =
-                    "Accessibility API Permission: \(AXIsProcessTrusted() ? "Granted" : "Not Granted")"
+                accessibilityAPIPermission.title = String(
+                    format: NSLocalizedString("Accessibility API Permission: %@", comment: ""),
+                    NSLocalizedString(
+                        AXIsProcessTrusted() ? "Granted" : "Not Granted",
+                        comment: ""
+                    )
+                )
             }
 
         case xcodeInspectorDebugMenuIdentifier:
@@ -222,14 +227,14 @@ extension AppDelegate: NSMenuDelegate {
             menu.items.append(.separator())
 
             menu.items.append(NSMenuItem(
-                title: "Restart Xcode Inspector",
+                title: NSLocalizedString("Restart Xcode Inspector", comment: ""),
                 action: #selector(restartXcodeInspector),
                 keyEquivalent: ""
             ))
             
             let isDebuggingOverlay = UserDefaults.shared.value(for: \.debugOverlayPanel)
             let debugOverlayItem = NSMenuItem(
-                title: "Debug Window Overlays",
+                title: NSLocalizedString("Debug Window Overlays", comment: ""),
                 action: #selector(toggleDebugOverlayPanel),
                 keyEquivalent: ""
             )
diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift
index cc830a3e..50cea6ef 100644
--- a/ExtensionService/AppDelegate.swift
+++ b/ExtensionService/AppDelegate.swift
@@ -30,6 +30,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
         shouldAutomaticallyCheckForUpdate: true
     )
 
+    func applicationWillFinishLaunching(_: Notification) {
+        AppLanguage.applyPreferredLanguage()
+    }
+
     func applicationDidFinishLaunching(_: Notification) {
 //        isPerceptionCheckingEnabled = false
         if ProcessInfo.processInfo.environment["IS_UNIT_TEST"] == "YES" { return }
diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings
new file mode 100644
index 00000000..d8fc8021
--- /dev/null
+++ b/Localization/Localizable.xcstrings
@@ -0,0 +1,10356 @@
+{
+  "sourceLanguage": "en",
+  "strings": {
+    " For more details, please visit [https://anthropic.com](https://anthropic.com).": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": " For more details, please visit [https://anthropic.com](https://anthropic.com)."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": " 更多详情请访问 [https://anthropic.com](https://anthropic.com)。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": " 更多詳情請前往 [https://anthropic.com](https://anthropic.com)。"
+          }
+        }
+      }
+    },
+    " For more details, please visit [https://ollama.com](https://ollama.com)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": " For more details, please visit [https://ollama.com](https://ollama.com)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": " 更多详情请访问 [https://ollama.com](https://ollama.com)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": " 更多詳情請前往 [https://ollama.com](https://ollama.com)"
+          }
+        }
+      }
+    },
+    " For more details, please visit [https://ollama.com](https://ollama.com).": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": " For more details, please visit [https://ollama.com](https://ollama.com)."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": " 更多详情请访问 [https://ollama.com](https://ollama.com)。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": " 更多詳情請前往 [https://ollama.com](https://ollama.com)。"
+          }
+        }
+      }
+    },
+    " If you don't have access to GPT-4, you may need to visit [https://platform.openai.com/account/billing/overview](https://platform.openai.com/account/billing/overview) to buy some credits. A ChatGPT Plus subscription is not enough to access GPT-4 through API.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": " If you don't have access to GPT-4, you may need to visit [https://platform.openai.com/account/billing/overview](https://platform.openai.com/account/billing/overview) to buy some credits. A ChatGPT Plus subscription is not enough to access GPT-4 through API."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": " 如果你无法访问 GPT-4,可能需要前往 [https://platform.openai.com/account/billing/overview](https://platform.openai.com/account/billing/overview) 购买额度。仅订阅 ChatGPT Plus 并不能通过 API 使用 GPT-4。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": " 如果你無法存取 GPT-4,可能需要前往 [https://platform.openai.com/account/billing/overview](https://platform.openai.com/account/billing/overview) 購買額度。僅訂閱 ChatGPT Plus 並不能透過 API 使用 GPT-4。"
+          }
+        }
+      }
+    },
+    " New Command": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": " New Command"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": " 新建命令"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": " 新增命令"
+          }
+        }
+      }
+    },
+    " New Command (%lld/10)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": " New Command (%lld/10)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": " 新建命令(%lld/10)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": " 新增命令(%lld/10)"
+          }
+        }
+      }
+    },
+    " Please login in the GitHub Copilot settings to use the model.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": " Please login in the GitHub Copilot settings to use the model."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": " 请先在 GitHub Copilot 设置中登录后再使用该模型。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": " 請先在 GitHub Copilot 設定中登入後再使用該模型。"
+          }
+        }
+      }
+    },
+    " This will call the APIs directly, which may not be allowed by GitHub. But it's used in other popular apps like Zed.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": " This will call the APIs directly, which may not be allowed by GitHub. But it's used in other popular apps like Zed."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": " 这会直接调用相关 API,GitHub 未必允许这种用法,但 Zed 等常见应用也是这么做的。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": " 這會直接呼叫相關 API,GitHub 未必允許這種用法,但 Zed 等常見應用程式也是這麼做的。"
+          }
+        }
+      }
+    },
+    " To get an API key, please visit [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": " To get an API key, please visit [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": " 获取 API 密钥请访问 [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": " 取得 API 金鑰請前往 [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys)"
+          }
+        }
+      }
+    },
+    " Use with any API that has the same format as Ollama. For more details, please visit [https://ollama.com](https://ollama.com)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": " Use with any API that has the same format as Ollama. For more details, please visit [https://ollama.com](https://ollama.com)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": " 可用于任何与 Ollama 格式相同的 API。更多详情请访问 [https://ollama.com](https://ollama.com)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": " 可用於任何與 Ollama 格式相同的 API。更多詳情請前往 [https://ollama.com](https://ollama.com)"
+          }
+        }
+      }
+    },
+    "%@ (Not found)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "%@ (Not found)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "%@(未找到)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "%@(找不到)"
+          }
+        }
+      }
+    },
+    "%lld tokens": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "%lld tokens"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "%lld 个 Token"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "%lld 個 Token"
+          }
+        }
+      }
+    },
+    "11 Messages": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "11 Messages"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "11 条消息"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "11 則訊息"
+          }
+        }
+      }
+    },
+    "111 Messages": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "111 Messages"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "111 条消息"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "111 則訊息"
+          }
+        }
+      }
+    },
+    "151 Messages": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "151 Messages"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "151 条消息"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "151 則訊息"
+          }
+        }
+      }
+    },
+    "201 Messages": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "201 Messages"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "201 条消息"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "201 則訊息"
+          }
+        }
+      }
+    },
+    "21 Messages": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "21 Messages"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "21 条消息"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "21 則訊息"
+          }
+        }
+      }
+    },
+    "3 Messages": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "3 Messages"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "3 条消息"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "3 則訊息"
+          }
+        }
+      }
+    },
+    "31 Messages": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "31 Messages"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "31 条消息"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "31 則訊息"
+          }
+        }
+      }
+    },
+    "41 Messages": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "41 Messages"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "41 条消息"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "41 則訊息"
+          }
+        }
+      }
+    },
+    "5 Messages": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "5 Messages"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "5 条消息"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "5 則訊息"
+          }
+        }
+      }
+    },
+    "51 Messages": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "51 Messages"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "51 条消息"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "51 則訊息"
+          }
+        }
+      }
+    },
+    "7 Messages": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "7 Messages"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "7 条消息"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "7 則訊息"
+          }
+        }
+      }
+    },
+    "71 Messages": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "71 Messages"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "71 条消息"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "71 則訊息"
+          }
+        }
+      }
+    },
+    "9 Messages": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "9 Messages"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "9 条消息"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "9 則訊息"
+          }
+        }
+      }
+    },
+    "91 Messages": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "91 Messages"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "91 条消息"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "91 則訊息"
+          }
+        }
+      }
+    },
+    "API Keys": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "API Keys"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "API 密钥"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "API 金鑰"
+          }
+        }
+      }
+    },
+    "API Version": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "API Version"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "API 版本"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "API 版本"
+          }
+        }
+      }
+    },
+    "Accept": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Accept"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "接受"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "接受"
+          }
+        }
+      }
+    },
+    "Accept Line": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Accept Line"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "接受本行"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "接受本行"
+          }
+        }
+      }
+    },
+    "Accept Modification": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Accept Modification"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "接受修改"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "接受修改"
+          }
+        }
+      }
+    },
+    "Accept Suggestion": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Accept Suggestion"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "接受建议"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "接受建議"
+          }
+        }
+      }
+    },
+    "Accept Suggestion Line": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Accept Suggestion Line"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "接受建议的一行"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "接受建議的一行"
+          }
+        }
+      }
+    },
+    "Accept and Continue(⌘ + ⏎)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Accept and Continue(⌘ + ⏎)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "接受并继续(⌘ + ⏎)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "接受並繼續(⌘ + ⏎)"
+          }
+        }
+      }
+    },
+    "Accept and Continue(⌥ + ⌘ + ⏎)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Accept and Continue(⌥ + ⌘ + ⏎)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "接受并继续(⌥ + ⌘ + ⏎)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "接受並繼續(⌥ + ⌘ + ⏎)"
+          }
+        }
+      }
+    },
+    "Accept suggestion first line with Control": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Accept suggestion first line with Control"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "使用 Control 键接受建议首行"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "使用 Control 鍵接受建議首行"
+          }
+        }
+      }
+    },
+    "Accept suggestion with Tab": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Accept suggestion with Tab"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "使用 Tab 键接受建议"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "使用 Tab 鍵接受建議"
+          }
+        }
+      }
+    },
+    "Accept suggestion with modifier": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Accept suggestion with modifier"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "使用修饰键接受建议"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "使用修飾鍵接受建議"
+          }
+        }
+      }
+    },
+    "Accept(⌘ + ⏎)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Accept(⌘ + ⏎)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "接受(⌘ + ⏎)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "接受(⌘ + ⏎)"
+          }
+        }
+      }
+    },
+    "Accept(⌥ + ⌘ + ⏎)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Accept(⌥ + ⌘ + ⏎)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "接受(⌥ + ⌘ + ⏎)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "接受(⌥ + ⌘ + ⏎)"
+          }
+        }
+      }
+    },
+    "Accessibility API Permission: N/A": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Accessibility API Permission: N/A"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "辅助功能 API 权限:不可用"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "輔助使用 API 權限:不可用"
+          }
+        }
+      }
+    },
+    "Accessibility API permission is not granted. Please enable in System Settings.app.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Accessibility API permission is not granted. Please enable in System Settings.app."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "尚未授予辅助功能 API 权限,请在「系统设置」中开启。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "尚未授予輔助使用 API 權限,請在「系統設定」中開啟。"
+          }
+        }
+      }
+    },
+    "Accessibility Permission: %@": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Accessibility Permission: %@"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "辅助功能权限:%@"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "輔助使用權限:%@"
+          }
+        }
+      }
+    },
+    "Accessibility Settings": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Accessibility Settings"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "辅助功能设置"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "輔助使用設定"
+          }
+        }
+      }
+    },
+    "Add": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Add"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "添加"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "新增"
+          }
+        }
+      }
+    },
+    "Add Model": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Add Model"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "添加模型"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "新增模型"
+          }
+        }
+      }
+    },
+    "Add context": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Add context"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "添加上下文"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "新增上下文"
+          }
+        }
+      }
+    },
+    "Add to Suggestion-Enabled Project List": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Add to Suggestion-Enabled Project List"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "添加到已启用建议的项目列表"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "加入已啟用建議的專案清單"
+          }
+        }
+      }
+    },
+    "Additional system prompt": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Additional system prompt"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "附加系统提示词"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "附加系統提示詞"
+          }
+        }
+      }
+    },
+    "Advanced": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Advanced"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "高级"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "進階"
+          }
+        }
+      }
+    },
+    "After renaming or adding a custom command, please restart Xcode to refresh the menu.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "After renaming or adding a custom command, please restart Xcode to refresh the menu."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "重命名或新增自定义命令后,请重启 Xcode 以刷新菜单。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "重新命名或新增自訂命令後,請重新啟動 Xcode 以更新選單。"
+          }
+        }
+      }
+    },
+    "Always": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Always"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "始终"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "一律"
+          }
+        }
+      }
+    },
+    "Always accept and continue": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Always accept and continue"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "始终接受并继续"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "一律接受並繼續"
+          }
+        }
+      }
+    },
+    "Always accept suggestion with Accessibility API": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Always accept suggestion with Accessibility API"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "始终通过辅助功能 API 接受建议"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "一律透過輔助使用 API 接受建議"
+          }
+        }
+      }
+    },
+    "Always-on-top behavior": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Always-on-top behavior"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "置顶行为"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "置頂行為"
+          }
+        }
+      }
+    },
+    "Anthropic Optimized": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Anthropic Optimized"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Anthropic 优化"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Anthropic 最佳化"
+          }
+        }
+      }
+    },
+    "Append to default system prompt": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Append to default system prompt"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "追加到默认系统提示词"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "附加到預設系統提示詞"
+          }
+        }
+      }
+    },
+    "Applies to the selected chat model. Setting a reasoning effort switches the request to the reasoning model shape: reasoning_effort is sent, max_completion_tokens replaces max_tokens, temperature and stop are omitted. Anthropic models always use /v1/messages. Headers sent to the gateway are only meaningful to the gateway itself and are not forwarded upstream.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Applies to the selected chat model. Setting a reasoning effort switches the request to the reasoning model shape: reasoning_effort is sent, max_completion_tokens replaces max_tokens, temperature and stop are omitted. Anthropic models always use /v1/messages. Headers sent to the gateway are only meaningful to the gateway itself and are not forwarded upstream."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "作用于所选聊天模型。设置推理强度后,请求会切换为推理模型的形态:发送 reasoning_effort,用 max_completion_tokens 取代 max_tokens,并省略 temperature 与 stop。Anthropic 模型始终使用 /v1/messages。发往网关的请求头只对网关本身有意义,不会转发到上游。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "作用於所選聊天模型。設定推理強度後,請求會切換為推理模型的形態:傳送 reasoning_effort,以 max_completion_tokens 取代 max_tokens,並省略 temperature 與 stop。Anthropic 模型一律使用 /v1/messages。發往閘道的標頭只對閘道本身有意義,不會轉發至上游。"
+          }
+        }
+      }
+    },
+    "Attach File Info": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Attach File Info"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "附带文件信息"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "附帶檔案資訊"
+          }
+        }
+      }
+    },
+    "Auth provider URL": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Auth provider URL"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "认证服务地址"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "驗證服務網址"
+          }
+        }
+      }
+    },
+    "Authentication Mode": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Authentication Mode"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "认证方式"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "驗證方式"
+          }
+        }
+      }
+    },
+    "Auto-detected by LLM": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Auto-detected by LLM"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "由大模型自动判断"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "由大型語言模型自動判斷"
+          }
+        }
+      }
+    },
+    "Automatically Check for Update": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Automatically Check for Update"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "自动检查更新"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "自動檢查更新"
+          }
+        }
+      }
+    },
+    "Base URL": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Base URL"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "基础地址"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "基礎網址"
+          }
+        }
+      }
+    },
+    "Base URL cannot be empty": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Base URL cannot be empty"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "基础地址不能为空"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "基礎網址不能為空"
+          }
+        }
+      }
+    },
+    "Basic": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Basic"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Basic"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Basic"
+          }
+        }
+      }
+    },
+    "Bearer Token": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Bearer Token"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Bearer Token"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Bearer Token"
+          }
+        }
+      }
+    },
+    "Buy Me A Coffee": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Buy Me A Coffee"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "请我喝杯咖啡"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "請我喝杯咖啡"
+          }
+        }
+      }
+    },
+    "Cache editor information on file open": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Cache editor information on file open"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "打开文件时缓存编辑器信息"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "開啟檔案時快取編輯器資訊"
+          }
+        }
+      }
+    },
+    "Can't find workspace.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Can't find workspace."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "找不到工作区。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "找不到工作區。"
+          }
+        }
+      }
+    },
+    "Cancel": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Cancel"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "取消"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "取消"
+          }
+        }
+      }
+    },
+    "Chat": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Chat"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "聊天"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "聊天"
+          }
+        }
+      }
+    },
+    "Chat Model": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Chat Model"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "聊天模型"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "聊天模型"
+          }
+        }
+      }
+    },
+    "Chat Model API": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Chat Model API"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "聊天模型 API"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "聊天模型 API"
+          }
+        }
+      }
+    },
+    "Chat Model Name": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Chat Model Name"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "聊天模型名称"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "聊天模型名稱"
+          }
+        }
+      }
+    },
+    "Chat Models": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Chat Models"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "聊天模型"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "聊天模型"
+          }
+        }
+      }
+    },
+    "Chat about your code": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Chat about your code"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "围绕你的代码进行对话"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "圍繞你的程式碼進行對話"
+          }
+        }
+      }
+    },
+    "Chat model": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Chat model"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "聊天模型"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "聊天模型"
+          }
+        }
+      }
+    },
+    "Chat web page URL": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Chat web page URL"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "聊天网页地址"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "聊天網頁網址"
+          }
+        }
+      }
+    },
+    "Chat, Modification": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Chat, Modification"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "聊天、修改"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "聊天、修改"
+          }
+        }
+      }
+    },
+    "Check for Updates": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Check for Updates"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "检查更新"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "檢查更新"
+          }
+        }
+      }
+    },
+    "Close": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Close"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "关闭"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "關閉"
+          }
+        }
+      }
+    },
+    "Close Idle Tabs": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Close Idle Tabs"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "关闭闲置标签页"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "關閉閒置標籤頁"
+          }
+        }
+      }
+    },
+    "Codeium API URL": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Codeium API URL"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Codeium API 地址"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Codeium API 網址"
+          }
+        }
+      }
+    },
+    "Codeium Enterprise Mode": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Codeium Enterprise Mode"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Codeium 企业版模式"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Codeium 企業版模式"
+          }
+        }
+      }
+    },
+    "Codeium Language Server": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Codeium Language Server"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Codeium 语言服务器"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Codeium 語言伺服器"
+          }
+        }
+      }
+    },
+    "Codeium Portal URL": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Codeium Portal URL"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Codeium 门户地址"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Codeium 入口網址"
+          }
+        }
+      }
+    },
+    "Codeium not signed in.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Codeium not signed in."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Codeium 未登录。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Codeium 未登入。"
+          }
+        }
+      }
+    },
+    "Command": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Command"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "命令"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "命令"
+          }
+        }
+      }
+    },
+    "Command Type": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Command Type"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "命令类型"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "命令類型"
+          }
+        }
+      }
+    },
+    "Command not found": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Command not found"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "找不到命令"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "找不到命令"
+          }
+        }
+      }
+    },
+    "Confirm Sign-in": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Confirm Sign-in"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "确认登录"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "確認登入"
+          }
+        }
+      }
+    },
+    "Context Window": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Context Window"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "上下文窗口"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "上下文視窗"
+          }
+        }
+      }
+    },
+    "Contexts": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Contexts"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "上下文"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "上下文"
+          }
+        }
+      }
+    },
+    "Continue": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Continue"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "继续"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "繼續"
+          }
+        }
+      }
+    },
+    "Continuous Mode": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Continuous Mode"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "连续模式"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "連續模式"
+          }
+        }
+      }
+    },
+    "Control": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Control"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Control"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Control"
+          }
+        }
+      }
+    },
+    "Conversation": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Conversation"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "对话"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "對話"
+          }
+        }
+      }
+    },
+    "Copilot.Vim Version: %@": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Copilot.Vim Version: %@"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Copilot.Vim 版本:%@"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Copilot.Vim 版本:%@"
+          }
+        }
+      }
+    },
+    "Copilot.Vim Version: %@ (Supported Version: %@)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Copilot.Vim Version: %@ (Supported Version: %@)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Copilot.Vim 版本:%1$@(支持的版本:%2$@)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Copilot.Vim 版本:%1$@(支援的版本:%2$@)"
+          }
+        }
+      }
+    },
+    "Copilot.Vim Version: %@ (Update Available: %@)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Copilot.Vim Version: %@ (Update Available: %@)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Copilot.Vim 版本:%1$@(可更新至 %2$@)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Copilot.Vim 版本:%1$@(可更新至 %2$@)"
+          }
+        }
+      }
+    },
+    "Copilot.Vim Version: Loading..": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Copilot.Vim Version: Loading.."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Copilot.Vim 版本:加载中……"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Copilot.Vim 版本:載入中……"
+          }
+        }
+      }
+    },
+    "Copilot.Vim Version: Not Installed": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Copilot.Vim Version: Not Installed"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Copilot.Vim 版本:未安装"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Copilot.Vim 版本:未安裝"
+          }
+        }
+      }
+    },
+    "Copy": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Copy"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "复制"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "複製"
+          }
+        }
+      }
+    },
+    "Custom Body": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Custom Body"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "自定义请求体"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "自訂請求內容"
+          }
+        }
+      }
+    },
+    "Custom Chat": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Custom Chat"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "自定义聊天"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "自訂聊天"
+          }
+        }
+      }
+    },
+    "Custom Command": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Custom Command"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "自定义命令"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "自訂命令"
+          }
+        }
+      }
+    },
+    "Custom Commands": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Custom Commands"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "自定义命令"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "自訂命令"
+          }
+        }
+      }
+    },
+    "Custom Header Field": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Custom Header Field"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "自定义请求头字段"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "自訂標頭欄位"
+          }
+        }
+      }
+    },
+    "Custom Headers": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Custom Headers"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "自定义请求头"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "自訂標頭"
+          }
+        }
+      }
+    },
+    "Custom Model": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Custom Model"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "自定义模型"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "自訂模型"
+          }
+        }
+      }
+    },
+    "Custom Model (Completion API)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Custom Model (Completion API)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "自定义模型(Completion API)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "自訂模型(Completion API)"
+          }
+        }
+      }
+    },
+    "Custom Model (FIM API)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Custom Model (FIM API)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "自定义模型(FIM API)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "自訂模型(FIM API)"
+          }
+        }
+      }
+    },
+    "Custom Value": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Custom Value"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "自定义值"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "自訂值"
+          }
+        }
+      }
+    },
+    "Dark": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Dark"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "深色"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "深色"
+          }
+        }
+      }
+    },
+    "Debug Window Overlays": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Debug Window Overlays"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "调试窗口浮层"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "除錯視窗覆蓋層"
+          }
+        }
+      }
+    },
+    "Decompressing..": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Decompressing.."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "正在解压……"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "正在解壓縮……"
+          }
+        }
+      }
+    },
+    "DeepSeek (OpenAI Compatible)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "DeepSeek (OpenAI Compatible)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "DeepSeek(OpenAI 兼容)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "DeepSeek(OpenAI 相容)"
+          }
+        }
+      }
+    },
+    "Default": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Default"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "默认"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "預設"
+          }
+        }
+      }
+    },
+    "Default (%@)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Default (%@)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "默认(%@)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "預設(%@)"
+          }
+        }
+      }
+    },
+    "Default (Selected Model Not Found)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Default (Selected Model Not Found)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "默认(未找到所选模型)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "預設(找不到所選模型)"
+          }
+        }
+      }
+    },
+    "Default Scopes": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Default Scopes"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "默认权限范围"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "預設權限範圍"
+          }
+        }
+      }
+    },
+    "Default Value": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Default Value"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "默认值"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "預設值"
+          }
+        }
+      }
+    },
+    "Delete": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Delete"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "删除"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "刪除"
+          }
+        }
+      }
+    },
+    "Deployment Name": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Deployment Name"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "部署名称"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "部署名稱"
+          }
+        }
+      }
+    },
+    "Detach Chat Panel": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Detach Chat Panel"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "分离聊天面板"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "分離聊天面板"
+          }
+        }
+      }
+    },
+    "Dimensions": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Dimensions"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "向量维度"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "向量維度"
+          }
+        }
+      }
+    },
+    "Disable GitHub Copilot settings auto refresh status on appear": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Disable GitHub Copilot settings auto refresh status on appear"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "打开 GitHub Copilot 设置时不自动刷新状态"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "開啟 GitHub Copilot 設定時不自動重新整理狀態"
+          }
+        }
+      }
+    },
+    "Disable Suggestion for \"%@\"": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Disable Suggestion for \"%@\""
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "为“%@”禁用建议"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "為「%@」停用建議"
+          }
+        }
+      }
+    },
+    "Disable always-on-top when the chat panel is detached": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Disable always-on-top when the chat panel is detached"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "聊天面板分离时不置顶"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "聊天面板分離時不置頂"
+          }
+        }
+      }
+    },
+    "Disable enhanced workspace": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Disable enhanced workspace"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "禁用增强工作区"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "停用增強工作區"
+          }
+        }
+      }
+    },
+    "Disable file content manipulation by cheatsheet": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Disable file content manipulation by cheatsheet"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "禁用基于速查表的文件内容改写"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "停用基於速查表的檔案內容改寫"
+          }
+        }
+      }
+    },
+    "Disable function calling for chat feature": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Disable function calling for chat feature"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "为聊天功能禁用函数调用"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "為聊天功能停用函式呼叫"
+          }
+        }
+      }
+    },
+    "Disable git ignore check": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Disable git ignore check"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "禁用 git ignore 检查"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "停用 git ignore 檢查"
+          }
+        }
+      }
+    },
+    "Disable suggestion feature globally": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Disable suggestion feature globally"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "全局禁用建议功能"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "全域停用建議功能"
+          }
+        }
+      }
+    },
+    "Disabled Languages": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Disabled Languages"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "已禁用的语言"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "已停用的語言"
+          }
+        }
+      }
+    },
+    "Disabled language list": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Disabled language list"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "已禁用的语言列表"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "已停用的語言清單"
+          }
+        }
+      }
+    },
+    "Dismiss": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Dismiss"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "忽略"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "忽略"
+          }
+        }
+      }
+    },
+    "Dismiss suggestion with ESC": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Dismiss suggestion with ESC"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "使用 ESC 键忽略建议"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "使用 ESC 鍵忽略建議"
+          }
+        }
+      }
+    },
+    "Don't install launch agent automatically": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Don't install launch agent automatically"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "不自动安装启动代理"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "不自動安裝啟動代理程式"
+          }
+        }
+      }
+    },
+    "Done": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Done"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "完成"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "完成"
+          }
+        }
+      }
+    },
+    "Done!": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Done!"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "完成!"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "完成!"
+          }
+        }
+      }
+    },
+    "Downloading..": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Downloading.."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "正在下载……"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "正在下載……"
+          }
+        }
+      }
+    },
+    "Duplicate": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Duplicate"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "创建副本"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "建立副本"
+          }
+        }
+      }
+    },
+    "Edit Model": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Edit Model"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "编辑模型"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "編輯模型"
+          }
+        }
+      }
+    },
+    "Embedding Models": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Embedding Models"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "嵌入模型"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "嵌入模型"
+          }
+        }
+      }
+    },
+    "Embedding model": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Embedding model"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "嵌入模型"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "嵌入模型"
+          }
+        }
+      }
+    },
+    "Empty": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Empty"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "空"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "空"
+          }
+        }
+      }
+    },
+    "Empty (Default Value)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Empty (Default Value)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "空(默认值)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "空(預設值)"
+          }
+        }
+      }
+    },
+    "Enable Indexing": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Enable Indexing"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "启用索引"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "啟用索引"
+          }
+        }
+      }
+    },
+    "Enable Suggestion for \"%@\"": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Enable Suggestion for \"%@\""
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "为“%@”启用建议"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "為「%@」啟用建議"
+          }
+        }
+      }
+    },
+    "Enable Xcode inspector debug menu": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Enable Xcode inspector debug menu"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "启用 Xcode Inspector 调试菜单"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "啟用 Xcode Inspector 除錯選單"
+          }
+        }
+      }
+    },
+    "Enable animation A": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Enable animation A"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "启用动画 A"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "啟用動畫 A"
+          }
+        }
+      }
+    },
+    "Enable animation B": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Enable animation B"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "启用动画 B"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "啟用動畫 B"
+          }
+        }
+      }
+    },
+    "Enable the Hotkey Globally": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Enable the Hotkey Globally"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "全局启用快捷键"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "全域啟用快速鍵"
+          }
+        }
+      }
+    },
+    "Enable widget breathing animation": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Enable widget breathing animation"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "启用组件呼吸动画"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "啟用元件呼吸動畫"
+          }
+        }
+      }
+    },
+    "Enabled Projects": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Enabled Projects"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "已启用的项目"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "已啟用的專案"
+          }
+        }
+      }
+    },
+    "Enforce message order to be user/assistant alternated": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Enforce message order to be user/assistant alternated"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "强制消息按用户/助手交替排列"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "強制訊息按使用者/助理交替排列"
+          }
+        }
+      }
+    },
+    "Engine": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Engine"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "引擎"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "引擎"
+          }
+        }
+      }
+    },
+    "Enter file path:": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Enter file path:"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "输入文件路径:"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "輸入檔案路徑:"
+          }
+        }
+      }
+    },
+    "Enter the root path of the project. Do not use `~` to replace /Users/yourUserName.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Enter the root path of the project. Do not use `~` to replace /Users/yourUserName."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "请输入项目的根路径。请勿用 `~` 代替 /Users/你的用户名。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "請輸入專案的根路徑。請勿用 `~` 代替 /Users/你的使用者名稱。"
+          }
+        }
+      }
+    },
+    "Enter your requirements to generate code.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Enter your requirements to generate code."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "输入你的需求以生成代码。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "輸入你的需求以產生程式碼。"
+          }
+        }
+      }
+    },
+    "Enterprise": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Enterprise"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "企业版"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "企業版"
+          }
+        }
+      }
+    },
+    "Error (Completed in %.2fs)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Error (Completed in %.2fs)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "失败(耗时 %.2f 秒)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "失敗(耗時 %.2f 秒)"
+          }
+        }
+      }
+    },
+    "Error message": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Error message"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "错误消息"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "錯誤訊息"
+          }
+        }
+      }
+    },
+    "Exception list": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Exception list"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "例外列表"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "例外清單"
+          }
+        }
+      }
+    },
+    "Export": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Export"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "导出"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "匯出"
+          }
+        }
+      }
+    },
+    "Extension Service Version: %@": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Extension Service Version: %@"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "扩展服务版本:%@"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "擴充服務版本:%@"
+          }
+        }
+      }
+    },
+    "Extensions": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Extensions"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "扩展"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "擴充功能"
+          }
+        }
+      }
+    },
+    "Extensions Settings": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Extensions Settings"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "扩展设置"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "擴充功能設定"
+          }
+        }
+      }
+    },
+    "Extra Context": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Extra Context"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "附加上下文"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "附加上下文"
+          }
+        }
+      }
+    },
+    "Extra Prompt:": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Extra Prompt:"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "附加提示词:"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "附加提示詞:"
+          }
+        }
+      }
+    },
+    "FIM Stop Token": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "FIM Stop Token"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "FIM 停止词"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "FIM 停止詞"
+          }
+        }
+      }
+    },
+    "FIM Template": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "FIM Template"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "FIM 模板"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "FIM 範本"
+          }
+        }
+      }
+    },
+    "Failed to construct chat URL.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Failed to construct chat URL."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "构造聊天地址失败。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "建構聊天網址失敗。"
+          }
+        }
+      }
+    },
+    "Failed to find language server. Please open an issue on GitHub.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Failed to find language server. Please open an issue on GitHub."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "找不到语言服务器,请在 GitHub 上提交 issue。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "找不到語言伺服器,請在 GitHub 上提交 issue。"
+          }
+        }
+      }
+    },
+    "Failed to install language server. Please open an issue on GitHub.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Failed to install language server. Please open an issue on GitHub."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "安装语言服务器失败,请在 GitHub 上提交 issue。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "安裝語言伺服器失敗,請在 GitHub 上提交 issue。"
+          }
+        }
+      }
+    },
+    "Failed to install start script.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Failed to install start script."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "安装启动脚本失败。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "安裝啟動指令碼失敗。"
+          }
+        }
+      }
+    },
+    "Feature": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Feature"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "功能"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "功能"
+          }
+        }
+      }
+    },
+    "Feature provider": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Feature provider"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "功能提供方"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "功能提供者"
+          }
+        }
+      }
+    },
+    "File path": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "File path"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "文件路径"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "檔案路徑"
+          }
+        }
+      }
+    },
+    "File...": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "File..."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "文件……"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "檔案……"
+          }
+        }
+      }
+    },
+    "File:": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "File:"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "文件:"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "檔案:"
+          }
+        }
+      }
+    },
+    "Fill-in-the-Middle (for models with FIM support, e.g. codellama:xb-code)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Fill-in-the-Middle (for models with FIM support, e.g. codellama:xb-code)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "中间填充 FIM(适用于支持 FIM 的模型,例如 codellama:xb-code)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "中間填充 FIM(適用於支援 FIM 的模型,例如 codellama:xb-code)"
+          }
+        }
+      }
+    },
+    "Fill-in-the-Middle with System Prompt": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Fill-in-the-Middle with System Prompt"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "带系统提示词的中间填充 FIM"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "帶系統提示詞的中間填充 FIM"
+          }
+        }
+      }
+    },
+    "Fixed to Bottom": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Fixed to Bottom"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "固定在底部"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "固定在底部"
+          }
+        }
+      }
+    },
+    "Floating widget": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Floating widget"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "悬浮组件"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "懸浮元件"
+          }
+        }
+      }
+    },
+    "Follow System": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Follow System"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "跟随系统"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "跟隨系統"
+          }
+        }
+      }
+    },
+    "Follow Text Cursor": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Follow Text Cursor"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "跟随文本光标"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "跟隨文字游標"
+          }
+        }
+      }
+    },
+    "Font": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Font"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "字体"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "字型"
+          }
+        }
+      }
+    },
+    "Font of code": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Font of code"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "代码字体"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "程式碼字型"
+          }
+        }
+      }
+    },
+    "Font size of message": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Font size of message"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "消息字体大小"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "訊息字型大小"
+          }
+        }
+      }
+    },
+    "Format": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Format"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "格式"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "格式"
+          }
+        }
+      }
+    },
+    "Found %lld results:": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Found %lld results:"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "找到 %lld 条结果:"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "找到 %lld 筆結果:"
+          }
+        }
+      }
+    },
+    "Full URL": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Full URL"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "完整地址"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "完整網址"
+          }
+        }
+      }
+    },
+    "Function calling is required by some features, if this model doesn't support function calling, you should turn it off to avoid undefined behaviors.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Function calling is required by some features, if this model doesn't support function calling, you should turn it off to avoid undefined behaviors."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "部分功能依赖函数调用。如果该模型不支持函数调用,请关闭此项以免出现未定义行为。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "部分功能依賴函式呼叫。如果該模型不支援函式呼叫,請關閉此項以免出現未定義行為。"
+          }
+        }
+      }
+    },
+    "General": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "General"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "通用"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "一般"
+          }
+        }
+      }
+    },
+    "Generate suggestions for your code": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Generate suggestions for your code"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "为你的代码生成建议"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "為你的程式碼產生建議"
+          }
+        }
+      }
+    },
+    "Generate suggestions with locally run or self-hosted models. Select \"Custom Model\" as the suggestion provider in Feature - Suggestion to use it.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Generate suggestions with locally run or self-hosted models. Select \"Custom Model\" as the suggestion provider in Feature - Suggestion to use it."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "使用本地运行或自建的模型生成建议。请在「功能 - 建议」中把建议提供方设为「自定义模型」后使用。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "使用本機執行或自架的模型產生建議。請在「功能 - 建議」中把建議提供者設為「自訂模型」後使用。"
+          }
+        }
+      }
+    },
+    "Get Suggestions": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Get Suggestions"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "获取建议"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "取得建議"
+          }
+        }
+      }
+    },
+    "GitHub Copilot (pick another model)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "GitHub Copilot (pick another model)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "GitHub Copilot(选择其他模型)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "GitHub Copilot(選擇其他模型)"
+          }
+        }
+      }
+    },
+    "GitHub Copilot Language Server": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "GitHub Copilot Language Server"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "GitHub Copilot 语言服务器"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "GitHub Copilot 語言伺服器"
+          }
+        }
+      }
+    },
+    "Granted": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Granted"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "已授权"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "已授權"
+          }
+        }
+      }
+    },
+    "Grok (OpenAI Compatible)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Grok (OpenAI Compatible)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Grok(OpenAI 兼容)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Grok(OpenAI 相容)"
+          }
+        }
+      }
+    },
+    "Header Field": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Header Field"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "请求头字段"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "標頭欄位"
+          }
+        }
+      }
+    },
+    "Header Field Name": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Header Field Name"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "请求头字段名"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "標頭欄位名稱"
+          }
+        }
+      }
+    },
+    "Header Name": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Header Name"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "请求头名称"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "標頭名稱"
+          }
+        }
+      }
+    },
+    "Headless Browser": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Headless Browser"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "无头浏览器"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "無頭瀏覽器"
+          }
+        }
+      }
+    },
+    "Headless Browser Settings": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Headless Browser Settings"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "无头浏览器设置"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "無頭瀏覽器設定"
+          }
+        }
+      }
+    },
+    "Hide buttons": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Hide buttons"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "隐藏按钮"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "隱藏按鈕"
+          }
+        }
+      }
+    },
+    "Hide common preceding spaces": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Hide common preceding spaces"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "隐藏公共前导空格"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "隱藏共同前導空格"
+          }
+        }
+      }
+    },
+    "Hide indicator widget": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Hide indicator widget"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "隐藏指示器组件"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "隱藏指示器元件"
+          }
+        }
+      }
+    },
+    "If you are not sure, run test to get the correct value.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "If you are not sure, run test to get the correct value."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "如果不确定,可以运行测试来获取正确的值。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "如果不確定,可以執行測試來取得正確的值。"
+          }
+        }
+      }
+    },
+    "Ignore existing contexts": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Ignore existing contexts"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "忽略已有上下文"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "忽略既有上下文"
+          }
+        }
+      }
+    },
+    "Import": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Import"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "导入"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "匯入"
+          }
+        }
+      }
+    },
+    "Indexing": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Indexing"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "索引"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "索引"
+          }
+        }
+      }
+    },
+    "Info message": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Info message"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "提示消息"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "提示訊息"
+          }
+        }
+      }
+    },
+    "Install": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Install"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "安装"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "安裝"
+          }
+        }
+      }
+    },
+    "Install beta builds": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Install beta builds"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "安装测试版"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "安裝測試版"
+          }
+        }
+      }
+    },
+    "Instruction": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Instruction"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "指令"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "指令"
+          }
+        }
+      }
+    },
+    "Invalid JSON object": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Invalid JSON object"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "JSON 对象不合法"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "JSON 物件不合法"
+          }
+        }
+      }
+    },
+    "Invalid data": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Invalid data"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "数据无效"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "資料無效"
+          }
+        }
+      }
+    },
+    "Invalid response": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Invalid response"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "响应无效"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "回應無效"
+          }
+        }
+      }
+    },
+    "Jump to File(⌘ + ⏎)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Jump to File(⌘ + ⏎)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "跳转到文件(⌘ + ⏎)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "跳至檔案(⌘ + ⏎)"
+          }
+        }
+      }
+    },
+    "Keep Alive": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Keep Alive"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "保持连接"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "保持連線"
+          }
+        }
+      }
+    },
+    "Keep always-on-top if the chat panel and Xcode overlaps and Xcode is active": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Keep always-on-top if the chat panel and Xcode overlaps and Xcode is active"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "当聊天面板与 Xcode 重叠且 Xcode 处于活动状态时保持置顶"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "當聊天面板與 Xcode 重疊且 Xcode 為使用中視窗時保持置頂"
+          }
+        }
+      }
+    },
+    "Key": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Key"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "键"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "鍵"
+          }
+        }
+      }
+    },
+    "Key not found: %@": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Key not found: %@"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "未找到密钥:%@"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "找不到金鑰:%@"
+          }
+        }
+      }
+    },
+    "Label": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Label"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "标签"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "標籤"
+          }
+        }
+      }
+    },
+    "Language": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Language"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "语言"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "語言"
+          }
+        }
+      }
+    },
+    "Language Server Version: %@": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Language Server Version: %@"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "语言服务器版本:%@"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "語言伺服器版本:%@"
+          }
+        }
+      }
+    },
+    "Language Server Version: %@ (Supported Version: %@)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Language Server Version: %@ (Supported Version: %@)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "语言服务器版本:%1$@(支持的版本:%2$@)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "語言伺服器版本:%1$@(支援的版本:%2$@)"
+          }
+        }
+      }
+    },
+    "Language Server Version: %@ (Update Available: %@)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Language Server Version: %@ (Update Available: %@)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "语言服务器版本:%1$@(可更新至 %2$@)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "語言伺服器版本:%1$@(可更新至 %2$@)"
+          }
+        }
+      }
+    },
+    "Language Server Version: Not Installed": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Language Server Version: Not Installed"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "语言服务器版本:未安装"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "語言伺服器版本:未安裝"
+          }
+        }
+      }
+    },
+    "Language server is installing.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Language server is installing."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "语言服务器正在安装。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "語言伺服器正在安裝。"
+          }
+        }
+      }
+    },
+    "Language server is not installed.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Language server is not installed."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "尚未安装语言服务器。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "尚未安裝語言伺服器。"
+          }
+        }
+      }
+    },
+    "Language server is not installed. Please install it in the host app.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Language server is not installed. Please install it in the host app."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "尚未安装语言服务器,请在主程序中安装。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "尚未安裝語言伺服器,請在主程式中安裝。"
+          }
+        }
+      }
+    },
+    "Language server is outdated. Please update it in the host app or update the extension.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Language server is outdated. Please update it in the host app or update the extension."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "语言服务器版本过旧,请在主程序中更新,或更新扩展本身。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "語言伺服器版本過舊,請在主程式中更新,或更新擴充功能本身。"
+          }
+        }
+      }
+    },
+    "Language service is installing, please try again later.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Language service is installing, please try again later."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "语言服务正在安装,请稍后重试。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "語言服務正在安裝,請稍後再試。"
+          }
+        }
+      }
+    },
+    "Launching service app.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Launching service app."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "正在启动服务应用。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "正在啟動服務應用程式。"
+          }
+        }
+      }
+    },
+    "Leave it blank if non is available.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Leave it blank if non is available."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "如果没有可用值,请留空。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "如果沒有可用值,請留空。"
+          }
+        }
+      }
+    },
+    "Light": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Light"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "浅色"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "淺色"
+          }
+        }
+      }
+    },
+    "Load Active Workspace": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Load Active Workspace"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "加载当前工作区"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "載入目前工作區"
+          }
+        }
+      }
+    },
+    "Load Current Workspace": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Load Current Workspace"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "加载当前工作区"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "載入目前工作區"
+          }
+        }
+      }
+    },
+    "Load certificates in keychain": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Load certificates in keychain"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "加载钥匙串中的证书"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "載入鑰匙圈中的憑證"
+          }
+        }
+      }
+    },
+    "Loading..": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Loading.."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "加载中……"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "載入中……"
+          }
+        }
+      }
+    },
+    "Max Input Tokens": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Max Input Tokens"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "最大输入 Token 数"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "最大輸入 Token 數"
+          }
+        }
+      }
+    },
+    "Max: %lld": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Max: %lld"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "上限:%lld"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "上限:%lld"
+          }
+        }
+      }
+    },
+    "Memory": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Memory"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "记忆"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "記憶"
+          }
+        }
+      }
+    },
+    "Mistral (OpenAI Compatible)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Mistral (OpenAI Compatible)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Mistral(OpenAI 兼容)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Mistral(OpenAI 相容)"
+          }
+        }
+      }
+    },
+    "Mode": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Mode"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "模式"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "模式"
+          }
+        }
+      }
+    },
+    "Model": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Model"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "模型"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "模型"
+          }
+        }
+      }
+    },
+    "Model ID cannot be empty": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Model ID cannot be empty"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "模型 ID 不能为空"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "模型 ID 不能為空"
+          }
+        }
+      }
+    },
+    "Model Name": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Model Name"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "模型名称"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "模型名稱"
+          }
+        }
+      }
+    },
+    "Model name cannot be empty": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Model name cannot be empty"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "模型名称不能为空"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "模型名稱不能為空"
+          }
+        }
+      }
+    },
+    "Modification": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Modification"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "修改"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "修改"
+          }
+        }
+      }
+    },
+    "More info": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "More info"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "更多信息"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "更多資訊"
+          }
+        }
+      }
+    },
+    "Naive": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Naive"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "朴素"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "樸素"
+          }
+        }
+      }
+    },
+    "Name": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Name"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "名称"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "名稱"
+          }
+        }
+      }
+    },
+    "Nearby text cursor": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Nearby text cursor"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "靠近文本光标"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "靠近文字游標"
+          }
+        }
+      }
+    },
+    "Never": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Never"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "从不"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "永不"
+          }
+        }
+      }
+    },
+    "New": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "New"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "新建"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "新增"
+          }
+        }
+      }
+    },
+    "New Command": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "New Command"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "新建命令"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "新增命令"
+          }
+        }
+      }
+    },
+    "New Key": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "New Key"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "新键"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "新鍵"
+          }
+        }
+      }
+    },
+    "New Value": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "New Value"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "新值"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "新值"
+          }
+        }
+      }
+    },
+    "Next Suggestion": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Next Suggestion"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "下一条建议"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "下一則建議"
+          }
+        }
+      }
+    },
+    "No API Key": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "No API Key"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "无 API 密钥"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "無 API 金鑰"
+          }
+        }
+      }
+    },
+    "No API key found, please add a new one →": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "No API key found, please add a new one →"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "未找到 API 密钥,请添加一个 →"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "找不到 API 金鑰,請新增一個 →"
+          }
+        }
+      }
+    },
+    "No Limit": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "No Limit"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "不限制"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "不限制"
+          }
+        }
+      }
+    },
+    "No Model Available": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "No Model Available"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "没有可用的模型"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "沒有可用的模型"
+          }
+        }
+      }
+    },
+    "No context": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "No context"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "无上下文"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "無上下文"
+          }
+        }
+      }
+    },
+    "No model found, please add a new one.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "No model found, please add a new one."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "未找到模型,请添加一个。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "找不到模型,請新增一個。"
+          }
+        }
+      }
+    },
+    "Node Settings": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Node Settings"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Node 设置"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Node 設定"
+          }
+        }
+      }
+    },
+    "None": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "None"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "无"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "無"
+          }
+        }
+      }
+    },
+    "Not Found": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Not Found"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "未找到"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "找不到"
+          }
+        }
+      }
+    },
+    "Not Granted": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Not Granted"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "未授权"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "未授權"
+          }
+        }
+      }
+    },
+    "Not Set": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Not Set"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "未设置"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "未設定"
+          }
+        }
+      }
+    },
+    "Not logged in.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Not logged in."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "尚未登录。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "尚未登入。"
+          }
+        }
+      }
+    },
+    "OK": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "OK"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "好"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "好"
+          }
+        }
+      }
+    },
+    "Observe to AXNotification with default mode": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Observe to AXNotification with default mode"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "以默认模式监听 AXNotification"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "以預設模式監聽 AXNotification"
+          }
+        }
+      }
+    },
+    "Ollama Compatible": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Ollama Compatible"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Ollama 兼容"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Ollama 相容"
+          }
+        }
+      }
+    },
+    "Only require modifiers for Swift": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Only require modifiers for Swift"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "仅 Swift 文件需要修饰键"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "僅 Swift 檔案需要修飾鍵"
+          }
+        }
+      }
+    },
+    "Open %@": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Open %@"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "打开%@"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "開啟%@"
+          }
+        }
+      }
+    },
+    "Open %@ tab": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Open %@ tab"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "打开「%@」标签页"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "開啟「%@」標籤頁"
+          }
+        }
+      }
+    },
+    "Open Chat": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Open Chat"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "打开聊天"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "開啟聊天"
+          }
+        }
+      }
+    },
+    "Open Chat Mode": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Open Chat Mode"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "打开聊天模式"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "開啟聊天模式"
+          }
+        }
+      }
+    },
+    "Open Extension Manager": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Open Extension Manager"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "打开扩展管理器"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "開啟擴充功能管理員"
+          }
+        }
+      }
+    },
+    "Open chat panel": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Open chat panel"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "打开聊天面板"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "開啟聊天面板"
+          }
+        }
+      }
+    },
+    "Open web page in browser": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Open web page in browser"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "在浏览器中打开网页"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "在瀏覽器中開啟網頁"
+          }
+        }
+      }
+    },
+    "Open web page in chat panel": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Open web page in chat panel"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "在聊天面板中打开网页"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "在聊天面板中開啟網頁"
+          }
+        }
+      }
+    },
+    "OpenAI Compatible": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "OpenAI Compatible"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "OpenAI 兼容"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "OpenAI 相容"
+          }
+        }
+      }
+    },
+    "OpenAI Endpoint": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "OpenAI Endpoint"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "OpenAI 接口地址"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "OpenAI 端點網址"
+          }
+        }
+      }
+    },
+    "OpenRouter (OpenAI Compatible)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "OpenRouter (OpenAI Compatible)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "OpenRouter(OpenAI 兼容)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "OpenRouter(OpenAI 相容)"
+          }
+        }
+      }
+    },
+    "Option": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Option"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Option"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Option"
+          }
+        }
+      }
+    },
+    "Optional": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Optional"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "可选"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "選填"
+          }
+        }
+      }
+    },
+    "Organization ID": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Organization ID"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "组织 ID"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "組織 ID"
+          }
+        }
+      }
+    },
+    "Original": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Original"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "原文"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "原文"
+          }
+        }
+      }
+    },
+    "Overwrite default system prompt": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Overwrite default system prompt"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "覆盖默认系统提示词"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "覆寫預設系統提示詞"
+          }
+        }
+      }
+    },
+    "PATH inherited from $SHELL configurations.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "PATH inherited from $SHELL configurations."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "PATH 继承自 $SHELL 的配置。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "PATH 繼承自 $SHELL 的設定。"
+          }
+        }
+      }
+    },
+    "PATH inherited from bash configurations.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "PATH inherited from bash configurations."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "PATH 继承自 bash 的配置。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "PATH 繼承自 bash 的設定。"
+          }
+        }
+      }
+    },
+    "Password": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Password"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "密码"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "密碼"
+          }
+        }
+      }
+    },
+    "Path to Node (v22.0+)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Path to Node (v22.0+)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Node 可执行文件路径(v22.0 及以上)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Node 執行檔路徑(v22.0 以上)"
+          }
+        }
+      }
+    },
+    "Please login in the GitHub Copilot settings to use the model.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Please login in the GitHub Copilot settings to use the model."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "请先在 GitHub Copilot 设置中登录后再使用该模型。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "請先在 GitHub Copilot 設定中登入後再使用該模型。"
+          }
+        }
+      }
+    },
+    "Plugin": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Plugin"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "插件"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "外掛"
+          }
+        }
+      }
+    },
+    "Prefer widget to be inside editor\nwhen width greater than": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Prefer widget to be inside editor\nwhen width greater than"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "当宽度大于此值时,优先将组件放在编辑器内"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "當寬度大於此值時,優先將元件放在編輯器內"
+          }
+        }
+      }
+    },
+    "Prefetch Suggestions": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Prefetch Suggestions"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "预取建议"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "預先取得建議"
+          }
+        }
+      }
+    },
+    "Prepare for Real-time Suggestions": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Prepare for Real-time Suggestions"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "为实时建议做准备"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "為即時建議做準備"
+          }
+        }
+      }
+    },
+    "Presentation": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Presentation"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "呈现方式"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "呈現方式"
+          }
+        }
+      }
+    },
+    "Pretend IDE to be VSCode": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Pretend IDE to be VSCode"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "将 IDE 伪装为 VSCode"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "將 IDE 偽裝為 VSCode"
+          }
+        }
+      }
+    },
+    "Previous Suggestion": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Previous Suggestion"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "上一条建议"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "上一則建議"
+          }
+        }
+      }
+    },
+    "Project ID": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Project ID"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "项目 ID"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "專案 ID"
+          }
+        }
+      }
+    },
+    "Prompt": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Prompt"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "提示词"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "提示詞"
+          }
+        }
+      }
+    },
+    "Provide the path to the executable if it can't be found by the app, shim executable is not supported": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Provide the path to the executable if it can't be found by the app, shim executable is not supported"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "若应用无法自动找到可执行文件,请提供其路径;不支持 shim 形式的可执行文件"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "若應用程式無法自動找到執行檔,請提供其路徑;不支援 shim 形式的執行檔"
+          }
+        }
+      }
+    },
+    "Proxy": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Proxy"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "代理"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "代理"
+          }
+        }
+      }
+    },
+    "Proxy host": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Proxy host"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "代理主机"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "代理主機"
+          }
+        }
+      }
+    },
+    "Proxy password": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Proxy password"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "代理密码"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "代理密碼"
+          }
+        }
+      }
+    },
+    "Proxy port": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Proxy port"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "代理端口"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "代理連接埠"
+          }
+        }
+      }
+    },
+    "Proxy strict SSL": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Proxy strict SSL"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "代理严格校验 SSL"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "代理嚴格驗證 SSL"
+          }
+        }
+      }
+    },
+    "Proxy username": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Proxy username"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "代理用户名"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "代理使用者名稱"
+          }
+        }
+      }
+    },
+    "Quit": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Quit"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "退出"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "結束"
+          }
+        }
+      }
+    },
+    "Quit service when Xcode and host app are terminated": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Quit service when Xcode and host app are terminated"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "当 Xcode 与主程序退出时结束服务"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "當 Xcode 與主程式結束時停止服務"
+          }
+        }
+      }
+    },
+    "Raw Prompt": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Raw Prompt"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "原始提示词"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "原始提示詞"
+          }
+        }
+      }
+    },
+    "Re-activate Xcode Inspector when Accessibility API malfunctioning detected": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Re-activate Xcode Inspector when Accessibility API malfunctioning detected"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "检测到辅助功能 API 异常时重新激活 Xcode Inspector"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "偵測到輔助使用 API 異常時重新啟用 Xcode Inspector"
+          }
+        }
+      }
+    },
+    "Reactivate Observations to Xcode": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Reactivate Observations to Xcode"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "重新激活对 Xcode 的监听"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "重新啟用對 Xcode 的監聽"
+          }
+        }
+      }
+    },
+    "Real-time suggestion": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Real-time suggestion"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "实时建议"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "即時建議"
+          }
+        }
+      }
+    },
+    "Real-time suggestion debounce": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Real-time suggestion debounce"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "实时建议防抖延迟"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "即時建議防抖延遲"
+          }
+        }
+      }
+    },
+    "Realtime Suggestion": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Realtime Suggestion"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "实时建议"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "即時建議"
+          }
+        }
+      }
+    },
+    "Reasoning Effort": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Reasoning Effort"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "推理强度"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "推理強度"
+          }
+        }
+      }
+    },
+    "Reasoning Token Budget": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Reasoning Token Budget"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "推理 Token 预算"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "推理 Token 預算"
+          }
+        }
+      }
+    },
+    "Receive response in notification": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Receive response in notification"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "在通知中接收回复"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "在通知中接收回覆"
+          }
+        }
+      }
+    },
+    "Refresh": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Refresh"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "刷新"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "重新整理"
+          }
+        }
+      }
+    },
+    "Refresh configurations": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Refresh configurations"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "刷新配置"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "重新整理設定"
+          }
+        }
+      }
+    },
+    "Reject": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Reject"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "拒绝"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "拒絕"
+          }
+        }
+      }
+    },
+    "Reject Suggestion": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Reject Suggestion"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "拒绝建议"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "拒絕建議"
+          }
+        }
+      }
+    },
+    "Reload": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Reload"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "重新加载"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "重新載入"
+          }
+        }
+      }
+    },
+    "Reload Launch Agent": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Reload Launch Agent"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "重新加载启动代理"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "重新載入啟動代理程式"
+          }
+        }
+      }
+    },
+    "Remove": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Remove"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "移除"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "移除"
+          }
+        }
+      }
+    },
+    "Remove Launch Agent": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Remove Launch Agent"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "移除启动代理"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "移除啟動代理程式"
+          }
+        }
+      }
+    },
+    "Remove from Suggestion-Enabled Project List": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Remove from Suggestion-Enabled Project List"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "从已启用建议的项目列表中移除"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "從已啟用建議的專案清單中移除"
+          }
+        }
+      }
+    },
+    "Replace and Continue(⌘ + ⏎)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Replace and Continue(⌘ + ⏎)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "替换并继续(⌘ + ⏎)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "取代並繼續(⌘ + ⏎)"
+          }
+        }
+      }
+    },
+    "Replace and Continue(⌥ + ⌘ + ⏎)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Replace and Continue(⌥ + ⌘ + ⏎)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "替换并继续(⌥ + ⌘ + ⏎)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "取代並繼續(⌥ + ⌘ + ⏎)"
+          }
+        }
+      }
+    },
+    "Replace(⌘ + ⏎)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Replace(⌘ + ⏎)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "替换(⌘ + ⏎)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "取代(⌘ + ⏎)"
+          }
+        }
+      }
+    },
+    "Replace(⌥ + ⌘ + ⏎)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Replace(⌥ + ⌘ + ⏎)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "替换(⌥ + ⌘ + ⏎)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "取代(⌥ + ⌘ + ⏎)"
+          }
+        }
+      }
+    },
+    "Reply in language": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Reply in language"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "回复所用语言"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "回覆所用語言"
+          }
+        }
+      }
+    },
+    "Request Strategy": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Request Strategy"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "请求策略"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "請求策略"
+          }
+        }
+      }
+    },
+    "Requires the first message to be from the user": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Requires the first message to be from the user"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "要求首条消息来自用户"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "要求首則訊息來自使用者"
+          }
+        }
+      }
+    },
+    "Reset 0.23.0 migration": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Reset 0.23.0 migration"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "重置 0.23.0 迁移"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "重設 0.23.0 移轉"
+          }
+        }
+      }
+    },
+    "Reset Default Scopes": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Reset Default Scopes"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "重置默认权限范围"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "重設預設權限範圍"
+          }
+        }
+      }
+    },
+    "Reset System Prompt": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Reset System Prompt"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "重置系统提示词"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "重設系統提示詞"
+          }
+        }
+      }
+    },
+    "Reset migration version to 0": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Reset migration version to 0"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "将迁移版本重置为 0"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "將移轉版本重設為 0"
+          }
+        }
+      }
+    },
+    "Reset update cycle": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Reset update cycle"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "重置更新周期"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "重設更新週期"
+          }
+        }
+      }
+    },
+    "Reset workspaces": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Reset workspaces"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "重置工作区"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "重設工作區"
+          }
+        }
+      }
+    },
+    "Restart Xcode Inspector": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Restart Xcode Inspector"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "重启 Xcode Inspector"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "重新啟動 Xcode Inspector"
+          }
+        }
+      }
+    },
+    "Restart the app and the extension service to switch language.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Restart the app and the extension service to switch language."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "请重启本应用与扩展服务以切换语言。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "請重新啟動本應用程式與擴充服務以切換語言。"
+          }
+        }
+      }
+    },
+    "Reveal Extension Service in Finder": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Reveal Extension Service in Finder"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "在访达中显示扩展服务"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "在 Finder 中顯示擴充服務"
+          }
+        }
+      }
+    },
+    "Root path": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Root path"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "根路径"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "根路徑"
+          }
+        }
+      }
+    },
+    "Run Node with": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Run Node with"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "运行 Node 的方式"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "執行 Node 的方式"
+          }
+        }
+      }
+    },
+    "Same as chat feature": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Same as chat feature"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "与聊天功能相同"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "與聊天功能相同"
+          }
+        }
+      }
+    },
+    "Save": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Save"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "保存"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "儲存"
+          }
+        }
+      }
+    },
+    "Saved!": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Saved!"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "已保存!"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "已儲存!"
+          }
+        }
+      }
+    },
+    "Search Provider": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Search Provider"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "搜索服务商"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "搜尋服務商"
+          }
+        }
+      }
+    },
+    "Search plugin max iterations": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Search plugin max iterations"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "搜索插件最大迭代次数"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "搜尋外掛最大迭代次數"
+          }
+        }
+      }
+    },
+    "Send Again": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Send Again"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "重新发送"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "重新傳送"
+          }
+        }
+      }
+    },
+    "Send Message": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Send Message"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "发送消息"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "傳送訊息"
+          }
+        }
+      }
+    },
+    "Send immediately": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Send immediately"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "立即发送"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "立即傳送"
+          }
+        }
+      }
+    },
+    "Serp API Settings": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Serp API Settings"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Serp API 设置"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Serp API 設定"
+          }
+        }
+      }
+    },
+    "Service": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Service"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "服务"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "服務"
+          }
+        }
+      }
+    },
+    "Set as Extra System Prompt": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Set as Extra System Prompt"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "设为附加系统提示词"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "設為附加系統提示詞"
+          }
+        }
+      }
+    },
+    "Setup Launch Agent": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Setup Launch Agent"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "配置启动代理"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "設定啟動代理程式"
+          }
+        }
+      }
+    },
+    "Shift": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Shift"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Shift"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Shift"
+          }
+        }
+      }
+    },
+    "Sign In": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Sign In"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "登录"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "登入"
+          }
+        }
+      }
+    },
+    "Sign Out": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Sign Out"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "退出登录"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "登出"
+          }
+        }
+      }
+    },
+    "Signing In..": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Signing In.."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "正在登录……"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "正在登入……"
+          }
+        }
+      }
+    },
+    "Single Round Dialog": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Single Round Dialog"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "单轮对话"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "單輪對話"
+          }
+        }
+      }
+    },
+    "Status: %@": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Status: %@"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "状态:%@"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "狀態:%@"
+          }
+        }
+      }
+    },
+    "Status: Not Signed In": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Status: Not Signed In"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "状态:未登录"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "狀態:未登入"
+          }
+        }
+      }
+    },
+    "Status: Signed In": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Status: Signed In"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "状态:已登录"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "狀態:已登入"
+          }
+        }
+      }
+    },
+    "Stop": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Stop"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "停止"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "停止"
+          }
+        }
+      }
+    },
+    "Stop Responding": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Stop Responding"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "停止回复"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "停止回覆"
+          }
+        }
+      }
+    },
+    "Store API keys in UserDefaults": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Store API keys in UserDefaults"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "将 API 密钥保存在 UserDefaults 中"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "將 API 金鑰儲存在 UserDefaults 中"
+          }
+        }
+      }
+    },
+    "Success (Completed in %.2fs)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Success (Completed in %.2fs)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "成功(耗时 %.2f 秒)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "成功(耗時 %.2f 秒)"
+          }
+        }
+      }
+    },
+    "Suggestion": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Suggestion"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "建议"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "建議"
+          }
+        }
+      }
+    },
+    "Suggestion Line Limit (0 means stop words only)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Suggestion Line Limit (0 means stop words only)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "建议行数上限(0 表示仅使用停止词)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "建議行數上限(0 表示僅使用停止詞)"
+          }
+        }
+      }
+    },
+    "Suggestion Token Limit": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Suggestion Token Limit"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "建议 Token 上限"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "建議 Token 上限"
+          }
+        }
+      }
+    },
+    "Support multi-part message content": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Support multi-part message content"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "支持多段式消息内容"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "支援多段式訊息內容"
+          }
+        }
+      }
+    },
+    "Supports Function Calling": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Supports Function Calling"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "支持函数调用"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "支援函式呼叫"
+          }
+        }
+      }
+    },
+    "Supports Images": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Supports Images"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "支持图片"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "支援圖片"
+          }
+        }
+      }
+    },
+    "Sync color scheme with Xcode": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Sync color scheme with Xcode"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "配色跟随 Xcode"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "配色跟隨 Xcode"
+          }
+        }
+      }
+    },
+    "System": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "System"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "跟随系统"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "跟隨系統"
+          }
+        }
+      }
+    },
+    "System Prompt": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "System Prompt"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "系统提示词"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "系統提示詞"
+          }
+        }
+      }
+    },
+    "System Prompt:": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "System Prompt:"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "系统提示词:"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "系統提示詞:"
+          }
+        }
+      }
+    },
+    "Tabs": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Tabs"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "标签页"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "標籤頁"
+          }
+        }
+      }
+    },
+    "Temperature": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Temperature"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "温度"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "溫度"
+          }
+        }
+      }
+    },
+    "Test": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Test"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "测试"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "測試"
+          }
+        }
+      }
+    },
+    "Test Result": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Test Result"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "测试结果"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "測試結果"
+          }
+        }
+      }
+    },
+    "Test Search": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Test Search"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "测试搜索"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "測試搜尋"
+          }
+        }
+      }
+    },
+    "The custom body will be added to the request body. Please use it to add parameters that are not yet available in the form. It should be a valid JSON object.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "The custom body will be added to the request body. Please use it to add parameters that are not yet available in the form. It should be a valid JSON object."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "自定义请求体会被合并进请求中,可用来添加表单里尚未提供的参数。内容必须是合法的 JSON 对象。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "自訂請求內容會被合併進請求中,可用來加入表單裡尚未提供的參數。內容必須是合法的 JSON 物件。"
+          }
+        }
+      }
+    },
+    "The user code is pasted into your clipboard, please paste it in the opened website to login.\nAfter that, click \"Confirm Sign-in\" to finish.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "The user code is pasted into your clipboard, please paste it in the opened website to login.\nAfter that, click \"Confirm Sign-in\" to finish."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "用户代码已复制到剪贴板,请在打开的网页中粘贴以完成登录。\n完成后点击「确认登录」结束流程。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "使用者代碼已複製到剪貼簿,請在開啟的網頁中貼上以完成登入。\n完成後點按「確認登入」結束流程。"
+          }
+        }
+      }
+    },
+    "Thinking...": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Thinking..."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "思考中……"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "思考中……"
+          }
+        }
+      }
+    },
+    "This command allows you to send a message to a temporary chat without opening the chat panel. It is particularly useful for one-time commands, such as running a terminal command with `/shell`. For example, you can set the prompt to `/shell open .` to open the project in Finder.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "This command allows you to send a message to a temporary chat without opening the chat panel. It is particularly useful for one-time commands, such as running a terminal command with `/shell`. For example, you can set the prompt to `/shell open .` to open the project in Finder."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "该命令可以在不打开聊天面板的情况下,向一个临时会话发送消息,特别适合一次性的命令,例如用 `/shell` 执行终端命令。比如把提示词设为 `/shell open .`,即可在访达中打开该项目。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "該命令可以在不開啟聊天面板的情況下,向一個暫時對話傳送訊息,特別適合一次性的命令,例如用 `/shell` 執行終端機命令。例如把提示詞設為 `/shell open .`,即可在 Finder 中開啟該專案。"
+          }
+        }
+      }
+    },
+    "This command opens the prompt-to-code panel and executes the provided requirements on the selected code. You can provide additional context through the \"Extra Context\" as well.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "This command opens the prompt-to-code panel and executes the provided requirements on the selected code. You can provide additional context through the \"Extra Context\" as well."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "该命令会打开「提示词转代码」面板,并对选中的代码执行你给出的需求。你也可以通过「附加上下文」提供更多信息。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "該命令會開啟「提示詞轉程式碼」面板,並對選取的程式碼執行你給出的需求。你也可以透過「附加上下文」提供更多資訊。"
+          }
+        }
+      }
+    },
+    "This command sends a message to the active chat tab. You can provide additional context as well. The additional context will be removed once a message is sent. If the message provided is empty, you can manually type the message in the chat.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "This command sends a message to the active chat tab. You can provide additional context as well. The additional context will be removed once a message is sent. If the message provided is empty, you can manually type the message in the chat."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "该命令会向当前聊天标签页发送一条消息,也可以附带额外的上下文;消息发出后附加上下文会被清除。如果没有填写消息内容,你可以在聊天中手动输入。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "該命令會向目前聊天標籤頁傳送一則訊息,也可以附帶額外的上下文;訊息送出後附加上下文會被清除。如果沒有填寫訊息內容,你可以在聊天中手動輸入。"
+          }
+        }
+      }
+    },
+    "This command will overwrite the context of the chat. You can use it to switch to different contexts in the chat. If a message is provided, it will be sent to the chat as well.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "This command will overwrite the context of the chat. You can use it to switch to different contexts in the chat. If a message is provided, it will be sent to the chat as well."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "该命令会覆盖聊天的上下文,可用来在不同上下文之间切换。如果填写了消息内容,也会一并发送到聊天中。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "該命令會覆寫聊天的上下文,可用來在不同上下文之間切換。如果填寫了訊息內容,也會一併傳送到聊天中。"
+          }
+        }
+      }
+    },
+    "Title": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Title"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "标题"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "標題"
+          }
+        }
+      }
+    },
+    "To refresh the theme, you must activate the extension service app once.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "To refresh the theme, you must activate the extension service app once."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "要刷新主题,需要先激活一次扩展服务应用。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "要更新佈景主題,需要先啟用一次擴充服務應用程式。"
+          }
+        }
+      }
+    },
+    "Toast for the reason of re-activation of Xcode Inspector": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Toast for the reason of re-activation of Xcode Inspector"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "以提示条显示重新激活 Xcode Inspector 的原因"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "以提示訊息顯示重新啟用 Xcode Inspector 的原因"
+          }
+        }
+      }
+    },
+    "Toggle Real-time Suggestions": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Toggle Real-time Suggestions"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "切换实时建议"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "切換即時建議"
+          }
+        }
+      }
+    },
+    "Token": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Token"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Token"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Token"
+          }
+        }
+      }
+    },
+    "Topic": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Topic"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "主题"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "主題"
+          }
+        }
+      }
+    },
+    "Trigger command with Accessibility API": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Trigger command with Accessibility API"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "通过辅助功能 API 触发命令"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "透過輔助使用 API 觸發命令"
+          }
+        }
+      }
+    },
+    "Trigger malfunctioning detection only with events": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Trigger malfunctioning detection only with events"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "仅在收到事件时触发异常检测"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "僅在收到事件時觸發異常偵測"
+          }
+        }
+      }
+    },
+    "UI": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "UI"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "界面"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "介面"
+          }
+        }
+      }
+    },
+    "URL": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "URL"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "地址"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "網址"
+          }
+        }
+      }
+    },
+    "URL is invalid": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "URL is invalid"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "地址无效"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "網址無效"
+          }
+        }
+      }
+    },
+    "Uninstall": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Uninstall"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "卸载"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "解除安裝"
+          }
+        }
+      }
+    },
+    "Uninstalling old version..": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Uninstalling old version.."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "正在卸载旧版本……"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "正在解除安裝舊版本……"
+          }
+        }
+      }
+    },
+    "Unknown Model (Use Custom Model Instead)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Unknown Model (Use Custom Model Instead)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "未知模型(改用自定义模型)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "未知模型(改用自訂模型)"
+          }
+        }
+      }
+    },
+    "Unknown Strategy (Use Default Strategy Instead)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Unknown Strategy (Use Default Strategy Instead)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "未知策略(改用默认策略)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "未知策略(改用預設策略)"
+          }
+        }
+      }
+    },
+    "Update": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Update"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "更新"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "更新"
+          }
+        }
+      }
+    },
+    "Use Cloudflare domain name for license check": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Use Cloudflare domain name for license check"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "使用 Cloudflare 域名进行许可证校验"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "使用 Cloudflare 網域進行授權驗證"
+          }
+        }
+      }
+    },
+    "Use custom scroll view workaround for smooth scrolling": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Use custom scroll view workaround for smooth scrolling"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "使用自定义滚动视图以获得更顺滑的滚动"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "使用自訂捲動視圖以取得更順暢的捲動"
+          }
+        }
+      }
+    },
+    "Use the default model": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Use the default model"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "使用默认模型"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "使用預設模型"
+          }
+        }
+      }
+    },
+    "Used %lld references": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Used %lld references"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "引用了 %lld 处参考"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "引用了 %lld 處參考"
+          }
+        }
+      }
+    },
+    "Usercode is empty.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Usercode is empty."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "用户代码为空。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "使用者代碼為空。"
+          }
+        }
+      }
+    },
+    "Username": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Username"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "用户名"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "使用者名稱"
+          }
+        }
+      }
+    },
+    "Utility chat model": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Utility chat model"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "辅助聊天模型"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "輔助聊天模型"
+          }
+        }
+      }
+    },
+    "Value": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Value"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "值"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "值"
+          }
+        }
+      }
+    },
+    "Verbose Log": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Verbose Log"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "详细日志"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "詳細記錄"
+          }
+        }
+      }
+    },
+    "Verbose Log (writes prompts including your source code to Console.app)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Verbose Log (writes prompts including your source code to Console.app)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "详细日志(会把包含你源代码的提示词写入“控制台”应用)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "詳細記錄(會把包含你原始碼的提示詞寫入「主控台」應用程式)"
+          }
+        }
+      }
+    },
+    "Verbose log": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Verbose log"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "详细日志"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "詳細記錄"
+          }
+        }
+      }
+    },
+    "Verification URI is incorrect.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Verification URI is incorrect."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "验证地址不正确。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "驗證網址不正確。"
+          }
+        }
+      }
+    },
+    "Voyage (OpenAI Compatible)": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Voyage (OpenAI Compatible)"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Voyage(OpenAI 兼容)"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Voyage(OpenAI 相容)"
+          }
+        }
+      }
+    },
+    "Warning message": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Warning message"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "警告消息"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "警告訊息"
+          }
+        }
+      }
+    },
+    "Web": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Web"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "网页"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "網頁"
+          }
+        }
+      }
+    },
+    "Web Search": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Web Search"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "网页搜索"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "網頁搜尋"
+          }
+        }
+      }
+    },
+    "When Xcode is active": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "When Xcode is active"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "当 Xcode 处于活动状态时"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "當 Xcode 為使用中視窗時"
+          }
+        }
+      }
+    },
+    "Widget color scheme": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Widget color scheme"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "悬浮组件配色"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "懸浮元件配色"
+          }
+        }
+      }
+    },
+    "Widget position": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Widget position"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "悬浮组件位置"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "懸浮元件位置"
+          }
+        }
+      }
+    },
+    "Wrap code": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Wrap code"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "代码自动换行"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "程式碼自動換行"
+          }
+        }
+      }
+    },
+    "Wrap text in code block": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Wrap text in code block"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "代码块内文本自动换行"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "程式碼區塊內文字自動換行"
+          }
+        }
+      }
+    },
+    "Write or Edit Code": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Write or Edit Code"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "编写或修改代码"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "撰寫或修改程式碼"
+          }
+        }
+      }
+    },
+    "Write or modify code with natural language": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Write or modify code with natural language"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "用自然语言编写或修改代码"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "用自然語言撰寫或修改程式碼"
+          }
+        }
+      }
+    },
+    "Xcode": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Xcode"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Xcode"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Xcode"
+          }
+        }
+      }
+    },
+    "Xcode Inspector Debug": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Xcode Inspector Debug"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Xcode Inspector 调试"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Xcode Inspector 除錯"
+          }
+        }
+      }
+    },
+    "Xcode related features": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Xcode related features"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "与 Xcode 相关的功能"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "與 Xcode 相關的功能"
+          }
+        }
+      }
+    },
+    "You can enter either an absolute path or a path relative to the project root.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "You can enter either an absolute path or a path relative to the project root."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "可以输入绝对路径,也可以输入相对于项目根目录的路径。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "可以輸入絕對路徑,也可以輸入相對於專案根目錄的路徑。"
+          }
+        }
+      }
+    },
+    "You will be prompted to grant the app permission to send notifications for the first time.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "You will be prompted to grant the app permission to send notifications for the first time."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "首次使用时,系统会请求你授予本应用发送通知的权限。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "首次使用時,系統會請求你授予本應用程式傳送通知的權限。"
+          }
+        }
+      }
+    },
+    "You will be redirected to codeium.com. Please paste the generated token below and click the \"Sign In\" button.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "You will be redirected to codeium.com. Please paste the generated token below and click the \"Sign In\" button."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "系统会跳转到 codeium.com。请把生成的 token 粘贴到下方,然后点击「登录」按钮。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "系統會跳轉到 codeium.com。請把產生的 token 貼到下方,然後點按「登入」按鈕。"
+          }
+        }
+      }
+    },
+    "current selection": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "current selection"
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "当前选中内容"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "目前選取內容"
+          }
+        }
+      }
+    },
+    "xxx.xxx.xxx.xxx, leave it blank to disable proxy.": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "xxx.xxx.xxx.xxx, leave it blank to disable proxy."
+          }
+        },
+        "zh-Hans": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "xxx.xxx.xxx.xxx,留空则不使用代理。"
+          }
+        },
+        "zh-Hant": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "xxx.xxx.xxx.xxx,留空則不使用代理。"
+          }
+        }
+      }
+    }
+  },
+  "version": "1.0"
+}
diff --git a/README.md b/README.md
index eb0e194d..6069ca7a 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,7 @@ Copilot for Xcode is an Xcode Source Editor Extension that provides GitHub Copil
 - Modification
 - Custom Commands to extend Chat and Modification.
 - Custom Model (built-in; OpenAI / OpenAI compatible / Responses API / Claude / Gemini / Ollama / Completion / FIM / Tabby)
+- Localized interface: English, 简体中文, 繁體中文. Pick one under General → Language, or leave it on "Follow System". The choice is shared by the host app, the extension service and the Xcode editor commands, and takes effect after a restart.
 
 ## Table of Contents
 
@@ -56,7 +57,8 @@ For more information, check the [Wiki Page](https://copilotforxcode.intii.com/wi
 
 ## Prerequisites
 
-- macOS 13.
+- A Mac with Apple Silicon. Intel Macs are not supported; the app ships an arm64-only binary.
+- macOS 15.6.
 - Public network connection.
 
 For suggestion features:
diff --git a/Tool/Sources/Preferences/AppLanguage.swift b/Tool/Sources/Preferences/AppLanguage.swift
new file mode 100644
index 00000000..a8bcd54b
--- /dev/null
+++ b/Tool/Sources/Preferences/AppLanguage.swift
@@ -0,0 +1,49 @@
+import Foundation
+
+/// The languages this build ships localizations for.
+///
+/// `Bundle` picks a localization from `AppleLanguages`, so an in-app choice has to be mirrored into
+/// the process' own defaults before the first localized lookup happens. Every executable that shows
+/// text does that in ``AppLanguage/applyPreferredLanguage()`` at the very start of its launch.
+public enum AppLanguage: String, CaseIterable, Codable, Equatable {
+    /// Follow the languages configured in System Settings.
+    case system = ""
+    case english = "en"
+    case simplifiedChinese = "zh-Hans"
+    case traditionalChinese = "zh-Hant"
+
+    /// The name of the language, always written in that language.
+    public var displayName: String {
+        switch self {
+        case .system: return NSLocalizedString("Follow System", comment: "App language option")
+        case .english: return "English"
+        case .simplifiedChinese: return "简体中文"
+        case .traditionalChinese: return "繁體中文"
+        }
+    }
+}
+
+public extension AppLanguage {
+    private static let appleLanguagesKey = "AppleLanguages"
+
+    /// Mirrors the shared language preference into this process' `AppleLanguages` override.
+    ///
+    /// Call this before any localized string is read. It is cheap and idempotent, so processes that
+    /// cannot pin down a single earliest moment may call it more than once.
+    static func applyPreferredLanguage() {
+        let preference = UserDefaults.shared.value(for: \.appLanguage)
+        let standard = UserDefaults.standard
+        let existing = standard.array(forKey: appleLanguagesKey) as? [String]
+
+        guard !preference.isEmpty else {
+            // Removing the app-level override hands the choice back to System Settings.
+            if existing != nil {
+                standard.removeObject(forKey: appleLanguagesKey)
+            }
+            return
+        }
+
+        guard existing != [preference] else { return }
+        standard.set([preference], forKey: appleLanguagesKey)
+    }
+}
diff --git a/Tool/Sources/Preferences/Keys.swift b/Tool/Sources/Preferences/Keys.swift
index 7b955d50..c8e5500c 100644
--- a/Tool/Sources/Preferences/Keys.swift
+++ b/Tool/Sources/Preferences/Keys.swift
@@ -105,6 +105,14 @@ public struct UserDefaultPreferenceKeys {
         defaultValue: false,
         key: "DebugOverlayPanel"
     )
+
+    // MARK: App Language
+
+    /// An empty value means "follow System Settings". See ``AppLanguage``.
+    public let appLanguage = PreferenceKey(
+        defaultValue: "",
+        key: "AppLanguage"
+    )
 }
 
 // MARK: - OpenAI Account Settings
diff --git a/Tool/Sources/SharedUIComponents/SettingsDivider.swift b/Tool/Sources/SharedUIComponents/SettingsDivider.swift
index 15db820d..73e4fdb7 100644
--- a/Tool/Sources/SharedUIComponents/SettingsDivider.swift
+++ b/Tool/Sources/SharedUIComponents/SettingsDivider.swift
@@ -30,7 +30,7 @@ public struct SettingsDivider: View {
 }
 
 extension SettingsDivider where Title == Text {
-    public init(_ title: String) {
+    public init(_ title: LocalizedStringKey) {
         self.title = Text(title)
     }
 }
diff --git a/Tool/Sources/SharedUIComponents/SubSection.swift b/Tool/Sources/SharedUIComponents/SubSection.swift
index 4fc78274..acc99083 100644
--- a/Tool/Sources/SharedUIComponents/SubSection.swift
+++ b/Tool/Sources/SharedUIComponents/SubSection.swift
@@ -44,7 +44,7 @@ public struct SubSection: View {
 }
 
 public extension SubSection where Description == Text {
-    init(title: Title, description: String, @ViewBuilder content: @escaping () -> Content) {
+    init(title: Title, description: LocalizedStringKey, @ViewBuilder content: @escaping () -> Content) {
         self.init(title: title, description: Text(description), content: content)
     }
 }
@@ -68,7 +68,7 @@ public extension SubSection where Title == EmptyView, Description == EmptyView {
 }
 
 public extension SubSection where Title == EmptyView, Description == Text {
-    init(description: String, @ViewBuilder content: @escaping () -> Content) {
+    init(description: LocalizedStringKey, @ViewBuilder content: @escaping () -> Content) {
         self.init(title: EmptyView(), description: description, content: content)
     }
 }
diff --git a/Tool/Sources/Toast/Toast.swift b/Tool/Sources/Toast/Toast.swift
index adcebfe1..63a49725 100644
--- a/Tool/Sources/Toast/Toast.swift
+++ b/Tool/Sources/Toast/Toast.swift
@@ -99,7 +99,7 @@ public class ToastController: ObservableObject {
             // Find existing message with same content and type (and namespace)
             if let existingIndex = messages.firstIndex(where: {
                 $0.type == type &&
-                $0.content == Text(content) &&
+                $0.content == Text(LocalizedStringKey(content)) &&
                 $0.namespace == namespace
             }) {
                 let existingMessage = messages[existingIndex]
@@ -121,7 +121,7 @@ public class ToastController: ObservableObject {
                 id: id,
                 type: type,
                 namespace: namespace,
-                content: Text(content),
+                content: Text(LocalizedStringKey(content)),
                 buttons: buttons.map { b in
                     Message.MessageButton(label: b.label, action: { [weak self] in
                         b.action()
diff --git a/Version.xcconfig b/Version.xcconfig
index 9fbbef26..deb5c5a7 100644
--- a/Version.xcconfig
+++ b/Version.xcconfig
@@ -1,4 +1,4 @@
-APP_VERSION = 0.38.0
-APP_BUILD = 504
+APP_VERSION = 0.40.0
+APP_BUILD = 505
 RELEASE_CHANNEL =
 RELEASE_NUMBER = 1