From 8c1a9b15d518804fa0282591bf7594f27c800bfd Mon Sep 17 00:00:00 2001 From: Todor Pitekov Date: Thu, 20 Feb 2025 09:25:18 +0200 Subject: [PATCH 01/29] add api token picker to ChatModelEditView --- .../AccountSettings/ChatModelManagement/ChatModelEditView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift index c6b74281..0875b1dc 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift @@ -380,6 +380,8 @@ struct ChatModelEditView: View { BaseURLTextField(store: store, prompt: Text("http://127.0.0.1:11434")) { Text("/api/chat") } + + ApiKeyNamePicker(store: store) TextField("Model Name", text: $store.modelName) From 5d79140612e659aedcbf517c223b07b9ed26566c Mon Sep 17 00:00:00 2001 From: Todor Pitekov Date: Thu, 20 Feb 2025 09:25:30 +0200 Subject: [PATCH 02/29] add Bearer header field --- .../OpenAIService/APIs/OlamaChatCompletionsService.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Tool/Sources/OpenAIService/APIs/OlamaChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OlamaChatCompletionsService.swift index f95b2c74..9d54b274 100644 --- a/Tool/Sources/OpenAIService/APIs/OlamaChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/OlamaChatCompletionsService.swift @@ -59,6 +59,7 @@ extension OllamaChatCompletionsService: ChatCompletionsAPI { 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.data(for: request) guard let response = response as? HTTPURLResponse else { @@ -135,6 +136,7 @@ extension OllamaChatCompletionsService: ChatCompletionsStreamAPI { 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 { From 6a90966afd3b71791bb8cf2cd80aedc6d791279a Mon Sep 17 00:00:00 2001 From: Todor Pitekov Date: Thu, 20 Feb 2025 09:26:28 +0200 Subject: [PATCH 03/29] optionally add api key to header --- .../APIs/OlamaChatCompletionsService.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Tool/Sources/OpenAIService/APIs/OlamaChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OlamaChatCompletionsService.swift index 9d54b274..96f872c0 100644 --- a/Tool/Sources/OpenAIService/APIs/OlamaChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/OlamaChatCompletionsService.swift @@ -59,7 +59,11 @@ extension OllamaChatCompletionsService: ChatCompletionsAPI { let encoder = JSONEncoder() request.httpBody = try encoder.encode(requestBody) request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + + if !apiKey.isEmpty { + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + } + let (result, response) = try await URLSession.shared.data(for: request) guard let response = response as? HTTPURLResponse else { @@ -136,7 +140,11 @@ extension OllamaChatCompletionsService: ChatCompletionsStreamAPI { let encoder = JSONEncoder() request.httpBody = try encoder.encode(requestBody) request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + + if !apiKey.isEmpty { + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + } + let (result, response) = try await URLSession.shared.bytes(for: request) guard let response = response as? HTTPURLResponse else { From 94bfdd5fe8fca3041b476a05f0d0658884a52caa Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 21 Feb 2025 15:51:00 +0800 Subject: [PATCH 04/29] Add API key field for Ollama embedding API --- .../EmbeddingModelManagement/EmbeddingModelEditView.swift | 3 +++ Tool/Sources/OpenAIService/APIs/OllamaEmbeddingService.swift | 5 +++++ Tool/Sources/OpenAIService/EmbeddingService.swift | 3 +++ 3 files changed, 11 insertions(+) diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift index 45014756..1569b9d2 100644 --- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift @@ -344,6 +344,9 @@ struct EmbeddingModelEditView: View { BaseURLTextField(store: store, prompt: Text("http://127.0.0.1:11434")) { Text("/api/embeddings") } + + ApiKeyNamePicker(store: store) + TextField("Model Name", text: $store.modelName) MaxTokensTextField(store: store) diff --git a/Tool/Sources/OpenAIService/APIs/OllamaEmbeddingService.swift b/Tool/Sources/OpenAIService/APIs/OllamaEmbeddingService.swift index dfd170cc..7a822df7 100644 --- a/Tool/Sources/OpenAIService/APIs/OllamaEmbeddingService.swift +++ b/Tool/Sources/OpenAIService/APIs/OllamaEmbeddingService.swift @@ -12,6 +12,7 @@ struct OllamaEmbeddingService: EmbeddingAPI { var embedding: [Float] } + let apiKey: String let model: EmbeddingModel let endpoint: String @@ -25,6 +26,10 @@ struct OllamaEmbeddingService: EmbeddingAPI { model: model.info.modelName )) request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + if !apiKey.isEmpty { + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + } let (result, response) = try await URLSession.shared.data(for: request) guard let response = response as? HTTPURLResponse else { diff --git a/Tool/Sources/OpenAIService/EmbeddingService.swift b/Tool/Sources/OpenAIService/EmbeddingService.swift index 0e54d3ac..a0d41f74 100644 --- a/Tool/Sources/OpenAIService/EmbeddingService.swift +++ b/Tool/Sources/OpenAIService/EmbeddingService.swift @@ -23,6 +23,7 @@ public struct EmbeddingService { ).embed(text: text) case .ollama: embeddingResponse = try await OllamaEmbeddingService( + apiKey: configuration.apiKey, model: model, endpoint: configuration.endpoint ).embed(text: text) @@ -54,6 +55,7 @@ public struct EmbeddingService { ).embed(texts: text) case .ollama: embeddingResponse = try await OllamaEmbeddingService( + apiKey: configuration.apiKey, model: model, endpoint: configuration.endpoint ).embed(texts: text) @@ -85,6 +87,7 @@ public struct EmbeddingService { ).embed(tokens: tokens) case .ollama: embeddingResponse = try await OllamaEmbeddingService( + apiKey: configuration.apiKey, model: model, endpoint: configuration.endpoint ).embed(tokens: tokens) From be47c9acfbf229297b3147d2018a2fbe10ed85f7 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 21 Feb 2025 16:22:18 +0800 Subject: [PATCH 05/29] Add custom header filed for Ollama APIs --- .../ChatModelEditView.swift | 18 ++++++++---- .../EmbeddingModelEditView.swift | 8 ++++++ .../APIs/OlamaChatCompletionsService.swift | 28 ++++++++++++------- .../APIs/OllamaEmbeddingService.swift | 4 +++ 4 files changed, 43 insertions(+), 15 deletions(-) diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift index 0875b1dc..3efa35da 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift @@ -243,7 +243,7 @@ struct ChatModelEditView: View { MaxTokensTextField(store: store) SupportsFunctionCallingToggle(store: store) - + TextField(text: $store.openAIOrganizationID, prompt: Text("Optional")) { Text("Organization ID") } @@ -321,11 +321,11 @@ struct ChatModelEditView: View { Toggle(isOn: $store.enforceMessageOrder) { Text("Enforce message order to be user/assistant alternated") } - + Toggle(isOn: $store.openAICompatibleSupportsMultipartMessageContent) { Text("Support multi-part message content") } - + Button("Custom Headers") { isEditingCustomHeader.toggle() } @@ -375,13 +375,15 @@ struct ChatModelEditView: View { struct OllamaForm: View { @Perception.Bindable var store: StoreOf + @State var isEditingCustomHeader = false + var body: some View { WithPerceptionTracking { BaseURLTextField(store: store, prompt: Text("http://127.0.0.1:11434")) { Text("/api/chat") } - - ApiKeyNamePicker(store: store) + + ApiKeyNamePicker(store: store) TextField("Model Name", text: $store.modelName) @@ -397,6 +399,12 @@ struct ChatModelEditView: View { ) } .padding(.vertical) + + Button("Custom Headers") { + isEditingCustomHeader.toggle() + } + }.sheet(isPresented: $isEditingCustomHeader) { + CustomHeaderSettingsView(headers: $store.customHeaders) } } } diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift index 1569b9d2..7c3ffa0b 100644 --- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift @@ -339,6 +339,8 @@ struct EmbeddingModelEditView: View { struct OllamaForm: View { @Perception.Bindable var store: StoreOf + @State var isEditingCustomHeader = false + var body: some View { WithPerceptionTracking { BaseURLTextField(store: store, prompt: Text("http://127.0.0.1:11434")) { @@ -364,6 +366,12 @@ struct EmbeddingModelEditView: View { ) } .padding(.vertical) + + Button("Custom Headers") { + isEditingCustomHeader.toggle() + } + }.sheet(isPresented: $isEditingCustomHeader) { + CustomHeaderSettingsView(headers: $store.customHeaders) } } } diff --git a/Tool/Sources/OpenAIService/APIs/OlamaChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OlamaChatCompletionsService.swift index 96f872c0..012dd410 100644 --- a/Tool/Sources/OpenAIService/APIs/OlamaChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/OlamaChatCompletionsService.swift @@ -59,11 +59,15 @@ extension OllamaChatCompletionsService: ChatCompletionsAPI { let encoder = JSONEncoder() request.httpBody = try encoder.encode(requestBody) request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - if !apiKey.isEmpty { - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - } - + + if !apiKey.isEmpty { + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + } + + for field in model.info.customHeaderInfo.headers where !field.key.isEmpty { + request.setValue(field.value, forHTTPHeaderField: field.key) + } + let (result, response) = try await URLSession.shared.data(for: request) guard let response = response as? HTTPURLResponse else { @@ -140,11 +144,15 @@ extension OllamaChatCompletionsService: ChatCompletionsStreamAPI { let encoder = JSONEncoder() request.httpBody = try encoder.encode(requestBody) request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - if !apiKey.isEmpty { - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - } - + + if !apiKey.isEmpty { + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + } + + for field in model.info.customHeaderInfo.headers where !field.key.isEmpty { + request.setValue(field.value, forHTTPHeaderField: field.key) + } + let (result, response) = try await URLSession.shared.bytes(for: request) guard let response = response as? HTTPURLResponse else { diff --git a/Tool/Sources/OpenAIService/APIs/OllamaEmbeddingService.swift b/Tool/Sources/OpenAIService/APIs/OllamaEmbeddingService.swift index 7a822df7..1e0f2933 100644 --- a/Tool/Sources/OpenAIService/APIs/OllamaEmbeddingService.swift +++ b/Tool/Sources/OpenAIService/APIs/OllamaEmbeddingService.swift @@ -30,6 +30,10 @@ struct OllamaEmbeddingService: EmbeddingAPI { if !apiKey.isEmpty { request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") } + + for field in model.info.customHeaderInfo.headers where !field.key.isEmpty { + request.setValue(field.value, forHTTPHeaderField: field.key) + } let (result, response) = try await URLSession.shared.data(for: request) guard let response = response as? HTTPURLResponse else { From f1cb5267c1f4fef3f7791834a289e0a0769a9138 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Fri, 21 Feb 2025 17:00:32 +0800 Subject: [PATCH 06/29] Update model format picker styles --- .../ChatModelManagement/ChatModelEdit.swift | 64 +++++++++++++++++++ .../ChatModelEditView.swift | 38 +++++++---- .../EmbeddingModelEdit.swift | 44 +++++++++++++ .../EmbeddingModelEditView.swift | 46 +++++++------ 4 files changed, 160 insertions(+), 32 deletions(-) diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift index f1407c31..831fc08c 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift @@ -45,10 +45,41 @@ struct ChatModelEdit { case testSucceeded(String) case testFailed(String) case checkSuggestedMaxTokens + case selectModelFormat(ModelFormat) case apiKeySelection(APIKeySelection.Action) case baseURLSelection(BaseURLSelection.Action) } + enum ModelFormat: CaseIterable { + case openAI + case azureOpenAI + case googleAI + case ollama + case claude + case openAICompatible + case deepSeekOpenAICompatible + case openRouterOpenAICompatible + case grokOpenAICompatible + case mistralOpenAICompatible + + init(_ format: ChatModel.Format) { + switch format { + case .openAI: + self = .openAI + case .azureOpenAI: + self = .azureOpenAI + case .googleAI: + self = .googleAI + case .ollama: + self = .ollama + case .claude: + self = .claude + case .openAICompatible: + self = .openAICompatible + } + } + } + var toast: (String, ToastType) -> Void { @Dependency(\.namespacedToast) var toast return { @@ -169,6 +200,39 @@ struct ChatModelEdit { return .none } + case let .selectModelFormat(format): + switch format { + case .openAI: + state.format = .openAI + case .azureOpenAI: + state.format = .azureOpenAI + case .googleAI: + state.format = .googleAI + case .ollama: + state.format = .ollama + case .claude: + state.format = .claude + case .openAICompatible: + state.format = .openAICompatible + case .deepSeekOpenAICompatible: + state.format = .openAICompatible + state.baseURLSelection.baseURL = "https://api.deepseek.com" + state.baseURLSelection.isFullURL = false + case .openRouterOpenAICompatible: + state.format = .openAICompatible + state.baseURLSelection.baseURL = "https://openrouter.ai" + state.baseURLSelection.isFullURL = false + case .grokOpenAICompatible: + state.format = .openAICompatible + state.baseURLSelection.baseURL = "https://api.x.ai" + state.baseURLSelection.isFullURL = false + case .mistralOpenAICompatible: + state.format = .openAICompatible + state.baseURLSelection.baseURL = "https://api.mistral.ai" + state.baseURLSelection.isFullURL = false + } + return .none + case .apiKeySelection: return .none diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift index 3efa35da..2a300f08 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift @@ -86,31 +86,42 @@ struct ChatModelEditView: View { var body: some View { WithPerceptionTracking { Picker( - selection: $store.format, + selection: Binding( + get: { .init(store.format) }, + set: { store.send(.selectModelFormat($0)) } + ), content: { ForEach( - ChatModel.Format.allCases, - id: \.rawValue + ChatModelEdit.ModelFormat.allCases, + id: \.self ) { format in switch format { case .openAI: - Text("OpenAI").tag(format) + Text("OpenAI") case .azureOpenAI: - Text("Azure OpenAI").tag(format) + Text("Azure OpenAI") case .openAICompatible: - Text("OpenAI Compatible").tag(format) + Text("OpenAI Compatible") case .googleAI: - Text("Google Generative AI").tag(format) + Text("Google AI") case .ollama: - Text("Ollama").tag(format) + Text("Ollama") case .claude: - Text("Claude").tag(format) + Text("Claude") + case .deepSeekOpenAICompatible: + Text("DeepSeek (OpenAI Compatible)") + case .openRouterOpenAICompatible: + Text("OpenRouter (OpenAI Compatible)") + case .grokOpenAICompatible: + Text("Grok (OpenAI Compatible)") + case .mistralOpenAICompatible: + Text("Mistral (OpenAI Compatible)") } } }, label: { Text("Format") } ) - .pickerStyle(.segmented) + .pickerStyle(.menu) } } } @@ -393,6 +404,10 @@ struct ChatModelEditView: View { Text("Keep Alive") } + Button("Custom Headers") { + isEditingCustomHeader.toggle() + } + VStack(alignment: .leading, spacing: 8) { Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( " For more details, please visit [https://ollama.com](https://ollama.com)." @@ -400,9 +415,6 @@ struct ChatModelEditView: View { } .padding(.vertical) - Button("Custom Headers") { - isEditingCustomHeader.toggle() - } }.sheet(isPresented: $isEditingCustomHeader) { CustomHeaderSettingsView(headers: $store.customHeaders) } diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift index 4e36a583..29efe52d 100644 --- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift @@ -41,9 +41,32 @@ struct EmbeddingModelEdit { case testFailed(String) case fixDimensions(Int) case checkSuggestedMaxTokens + case selectModelFormat(ModelFormat) case apiKeySelection(APIKeySelection.Action) case baseURLSelection(BaseURLSelection.Action) } + + enum ModelFormat: CaseIterable { + case openAI + case azureOpenAI + case ollama + case openAICompatible + case mistralOpenAICompatible + case voyageAIOpenAICompatible + + init(_ format: EmbeddingModel.Format) { + switch format { + case .openAI: + self = .openAI + case .azureOpenAI: + self = .azureOpenAI + case .ollama: + self = .ollama + case .openAICompatible: + self = .openAICompatible + } + } + } var toast: (String, ToastType) -> Void { @Dependency(\.namespacedToast) var toast @@ -155,6 +178,27 @@ struct EmbeddingModelEdit { case let .fixDimensions(value): state.dimensions = value return .none + + case let .selectModelFormat(format): + switch format { + case .openAI: + state.format = .openAI + case .azureOpenAI: + state.format = .azureOpenAI + case .ollama: + state.format = .ollama + case .openAICompatible: + state.format = .openAICompatible + case .mistralOpenAICompatible: + state.format = .openAICompatible + state.baseURLSelection.baseURL = "https://api.mistral.ai" + state.baseURLSelection.isFullURL = false + case .voyageAIOpenAICompatible: + state.format = .openAICompatible + state.baseURLSelection.baseURL = "https://api.voyage.ai" + state.baseURLSelection.isFullURL = false + } + return .none case .apiKeySelection: return .none diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift index 7c3ffa0b..a0bda5cd 100644 --- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift @@ -81,27 +81,34 @@ struct EmbeddingModelEditView: View { var body: some View { WithPerceptionTracking { Picker( - selection: $store.format, + selection: Binding( + get: { .init(store.format) }, + set: { store.send(.selectModelFormat($0)) } + ), content: { ForEach( - EmbeddingModel.Format.allCases, - id: \.rawValue + EmbeddingModelEdit.ModelFormat.allCases, + id: \.self ) { format in switch format { case .openAI: - Text("OpenAI").tag(format) + Text("OpenAI") case .azureOpenAI: - Text("Azure OpenAI").tag(format) - case .openAICompatible: - Text("OpenAI Compatible").tag(format) + Text("Azure OpenAI") case .ollama: - Text("Ollama").tag(format) + Text("Ollama") + case .openAICompatible: + Text("OpenAI Compatible") + case .mistralOpenAICompatible: + Text("Mistral (OpenAI Compatible)") + case .voyageAIOpenAICompatible: + Text("Voyage (OpenAI Compatible)") } } }, label: { Text("Format") } ) - .pickerStyle(.segmented) + .pickerStyle(.menu) } } } @@ -174,7 +181,7 @@ struct EmbeddingModelEditView: View { } } } - + struct DimensionsTextField: View { @Perception.Bindable var store: StoreOf @@ -212,7 +219,7 @@ struct EmbeddingModelEditView: View { return .primary }() as Color) } - + Text("If you are not sure, run test to get the correct value.") .font(.caption) .dynamicHeightTextInFormWorkaround() @@ -327,7 +334,7 @@ struct EmbeddingModelEditView: View { MaxTokensTextField(store: store) DimensionsTextField(store: store) - + Button("Custom Headers") { isEditingCustomHeader.toggle() } @@ -340,15 +347,15 @@ struct EmbeddingModelEditView: View { struct OllamaForm: View { @Perception.Bindable var store: StoreOf @State var isEditingCustomHeader = false - + var body: some View { WithPerceptionTracking { BaseURLTextField(store: store, prompt: Text("http://127.0.0.1:11434")) { Text("/api/embeddings") } - + ApiKeyNamePicker(store: store) - + TextField("Model Name", text: $store.modelName) MaxTokensTextField(store: store) @@ -360,16 +367,17 @@ struct EmbeddingModelEditView: View { } } + Button("Custom Headers") { + isEditingCustomHeader.toggle() + } + 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) - - Button("Custom Headers") { - isEditingCustomHeader.toggle() - } + }.sheet(isPresented: $isEditingCustomHeader) { CustomHeaderSettingsView(headers: $store.customHeaders) } From 4298221c8abd26abd28f09f2f0a7d228a5a025cd Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 23 Feb 2025 17:20:37 +0800 Subject: [PATCH 07/29] Add id to references --- Tool/Sources/ChatBasic/ChatAgent.swift | 2 +- Tool/Sources/ChatBasic/ChatMessage.swift | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Tool/Sources/ChatBasic/ChatAgent.swift b/Tool/Sources/ChatBasic/ChatAgent.swift index 7f464881..b159be16 100644 --- a/Tool/Sources/ChatBasic/ChatAgent.swift +++ b/Tool/Sources/ChatBasic/ChatAgent.swift @@ -4,7 +4,7 @@ public enum ChatAgentResponse { public enum Content { case text(String) } - + public enum ActionResult { case success(String) case failure(String) diff --git a/Tool/Sources/ChatBasic/ChatMessage.swift b/Tool/Sources/ChatBasic/ChatMessage.swift index a0f7d432..23e57000 100644 --- a/Tool/Sources/ChatBasic/ChatMessage.swift +++ b/Tool/Sources/ChatBasic/ChatMessage.swift @@ -59,7 +59,7 @@ public struct ChatMessage: Equatable, Codable { } /// A reference to include in a chat message. - public struct Reference: Codable, Equatable { + public struct Reference: Codable, Equatable, Identifiable { /// The kind of reference. public enum Kind: Codable, Equatable { public enum Symbol: String, Codable { @@ -89,6 +89,8 @@ public struct ChatMessage: Equatable, Codable { case error } + @FallbackDecoding + public var id: String /// The title of the reference. public var title: String /// The content of the reference. @@ -98,10 +100,12 @@ public struct ChatMessage: Equatable, Codable { public var kind: Kind public init( + id: String = UUID().uuidString, title: String, content: String, kind: Kind ) { + self.id = id self.title = title self.content = content self.kind = kind @@ -188,6 +192,10 @@ public struct ReferenceKindFallback: FallbackValueProvider { public static var defaultValue: ChatMessage.Reference.Kind { .other(kind: "Unknown") } } +public struct ReferenceIDFallback: FallbackValueProvider { + public static var defaultValue: String { UUID().uuidString } +} + public struct ChatMessageRoleFallback: FallbackValueProvider { public static var defaultValue: ChatMessage.Role { .user } } From 8a0b1213a3fb15793a528e325a619a16ca1e8250 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 23 Feb 2025 18:51:09 +0800 Subject: [PATCH 08/29] Support reasoning content from DeepSeek --- Core/Sources/ChatService/AllPlugins.swift | 2 ++ Tool/Sources/ChatBasic/ChatAgent.swift | 2 ++ .../APIs/ChatCompletionsAPIDefinition.swift | 3 +++ .../APIs/OpenAIChatCompletionsService.swift | 7 +++++++ Tool/Sources/OpenAIService/ChatGPTService.swift | 9 +++++++++ 5 files changed, 23 insertions(+) diff --git a/Core/Sources/ChatService/AllPlugins.swift b/Core/Sources/ChatService/AllPlugins.swift index be6c9846..82e756ec 100644 --- a/Core/Sources/ChatService/AllPlugins.swift +++ b/Core/Sources/ChatService/AllPlugins.swift @@ -94,6 +94,8 @@ final class LegacyChatPluginWrapper: LegacyChatPlugin { break case .startNewMessage: break + case .reasoning: + break } await chatGPTService.memory.mutateHistory { history in diff --git a/Tool/Sources/ChatBasic/ChatAgent.swift b/Tool/Sources/ChatBasic/ChatAgent.swift index b159be16..763233b9 100644 --- a/Tool/Sources/ChatBasic/ChatAgent.swift +++ b/Tool/Sources/ChatBasic/ChatAgent.swift @@ -24,6 +24,8 @@ public enum ChatAgentResponse { case references([ChatMessage.Reference]) /// End the current message. The next contents will be sent as a new message. case startNewMessage + /// Reasoning + case reasoning(String) } public struct ChatAgentRequest { diff --git a/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIDefinition.swift b/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIDefinition.swift index f8f1a5ff..5c25aaa2 100644 --- a/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIDefinition.swift +++ b/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIDefinition.swift @@ -209,6 +209,7 @@ struct ChatCompletionsStreamDataChunk { var role: ChatCompletionsRequestBody.Message.Role? var content: String? + var reasoningContent: String? var toolCalls: [ToolCall]? } @@ -243,6 +244,8 @@ struct ChatCompletionResponseBody: Equatable { var role: Role /// The content of the message. var content: String? + /// The reasoning content of the message. + var reasoningContent: String? /// When we want to reply to a function call with the result, we have to provide the /// name of the function call, and include the result in `content`. /// diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift index 1ad0c4d9..af90fcaa 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift @@ -100,6 +100,8 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI struct Delta: Codable { var role: MessageRole? var content: String? + var reasoning_content: String? + var reasoning: String? var function_call: RequestBody.MessageFunctionCall? var tool_calls: [RequestBody.MessageToolCall]? } @@ -112,6 +114,8 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI var role: MessageRole /// The content of the message. var content: String? + var reasoning_content: String? + var reasoning: String? /// When we want to reply to a function call with the result, we have to provide the /// name of the function call, and include the result in `content`. /// @@ -482,6 +486,7 @@ extension OpenAIChatCompletionsService.ResponseBody { .init( role: message.role.formalized, content: message.content ?? "", + reasoningContent: message.reasoning_content ?? message.reasoning ?? "", toolCalls: { if let toolCalls = message.tool_calls { return toolCalls.map { toolCall in @@ -553,6 +558,8 @@ extension OpenAIChatCompletionsService.StreamDataChunk { return .init( role: choice.delta?.role?.formalized, content: choice.delta?.content, + reasoningContent: choice.delta?.reasoning_content + ?? choice.delta?.reasoning, toolCalls: { if let toolCalls = choice.delta?.tool_calls { return toolCalls.map { diff --git a/Tool/Sources/OpenAIService/ChatGPTService.swift b/Tool/Sources/OpenAIService/ChatGPTService.swift index c10cb01c..986a7396 100644 --- a/Tool/Sources/OpenAIService/ChatGPTService.swift +++ b/Tool/Sources/OpenAIService/ChatGPTService.swift @@ -66,6 +66,7 @@ public struct ChatGPTError: Error, Codable, LocalizedError { public enum ChatGPTResponse: Equatable { case status([String]) case partialText(String) + case partialReasoning(String) case toolCalls([ChatMessage.ToolCall]) } @@ -195,6 +196,9 @@ public class ChatGPTService: ChatGPTServiceType { switch content { case let .partialText(text): continuation.yield(ChatGPTResponse.partialText(text)) + + case let .partialReasoning(text): + continuation.yield(ChatGPTResponse.partialReasoning(text)) case let .partialToolCalls(toolCalls): guard configuration.runFunctionsAutomatically else { break } @@ -250,6 +254,7 @@ public class ChatGPTService: ChatGPTServiceType { extension ChatGPTService { enum StreamContent { + case partialReasoning(String) case partialText(String) case partialToolCalls([Int: ChatMessage.ToolCall]) } @@ -340,6 +345,10 @@ extension ChatGPTService { if let content = delta.content { continuation.yield(.partialText(content)) } + + if let reasoning = delta.reasoningContent { + continuation.yield(.partialReasoning(reasoning)) + } } Logger.service.info("ChatGPT usage: \(usage)") From 316d91d8c331d68588bc6841ec4729b16daf345f Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 23 Feb 2025 22:02:27 +0800 Subject: [PATCH 09/29] Update --- Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift index d2062450..4965efbf 100644 --- a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift +++ b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift @@ -103,8 +103,6 @@ final class TabToAcceptSuggestion { let tab = 48 let esc = 53 - Logger.service.info("TabToAcceptSuggestion: \(keycode)") - switch keycode { case tab: Logger.service.info("TabToAcceptSuggestion: Tab") From a7a34fabf1aee7a81842e29c86e76fe279717845 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Sun, 23 Feb 2025 22:02:38 +0800 Subject: [PATCH 10/29] Fix embedding empty array --- .../APIs/OpenAIEmbeddingService.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift index d6fe2780..80551a1b 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift @@ -23,6 +23,13 @@ struct OpenAIEmbeddingService: EmbeddingAPI { public func embed(texts text: [String]) async throws -> EmbeddingResponse { guard let url = URL(string: endpoint) else { throw ChatGPTServiceError.endpointIncorrect } + if text.isEmpty { + return .init( + data: [], + model: model.info.modelName, + usage: .init(prompt_tokens: 0, total_tokens: 0) + ) + } var request = URLRequest(url: url) request.httpMethod = "POST" let encoder = JSONEncoder() @@ -55,6 +62,13 @@ struct OpenAIEmbeddingService: EmbeddingAPI { public func embed(tokens: [[Int]]) async throws -> EmbeddingResponse { guard let url = URL(string: endpoint) else { throw ChatGPTServiceError.endpointIncorrect } + if tokens.isEmpty { + return .init( + data: [], + model: model.info.modelName, + usage: .init(prompt_tokens: 0, total_tokens: 0) + ) + } var request = URLRequest(url: url) request.httpMethod = "POST" let encoder = JSONEncoder() @@ -126,7 +140,7 @@ struct OpenAIEmbeddingService: EmbeddingAPI { } } } - + static func setupExtraHeaderFields(_ request: inout URLRequest, model: EmbeddingModel) { for field in model.info.customHeaderInfo.headers where !field.key.isEmpty { request.setValue(field.value, forHTTPHeaderField: field.key) From 88ce86fa280a6144fe829822e6f25100c7131518 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 24 Feb 2025 01:56:24 +0800 Subject: [PATCH 11/29] Update UI --- .../HostApp/CustomCommandSettings/EditCustomCommandView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift index d42cac83..e2304f8b 100644 --- a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift +++ b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift @@ -263,7 +263,7 @@ extension CustomCommand.Attachment.Kind { case .senseScope: return "Sense Scope" case .projectScope: return "Project Scope" case .webScope: return "Web Scope" - case .gitStatus: return "Git Status" + case .gitStatus: return "Git Status and Diff" case .gitLog: return "Git Log" case .file: return "File" } From e4b2336d5bfb644e5fa4737cf353a8a350727765 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 24 Feb 2025 14:46:47 +0800 Subject: [PATCH 12/29] Add request modifier --- .../OpenAIService/APIs/OpenAIChatCompletionsService.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift index af90fcaa..25b96bfc 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift @@ -290,12 +290,14 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI var endpoint: URL var requestBody: RequestBody var model: ChatModel + let requestModifier: ((inout URLRequest) -> Void)? init( apiKey: String, model: ChatModel, endpoint: URL, - requestBody: ChatCompletionsRequestBody + requestBody: ChatCompletionsRequestBody, + requestModifier: ((inout URLRequest) -> Void)? = nil ) { self.apiKey = apiKey self.endpoint = endpoint @@ -310,6 +312,7 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI supportsAudio: model.info.supportsAudio ) self.model = model + self.requestModifier = requestModifier } func callAsFunction() async throws @@ -325,6 +328,7 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI Self.setupAppInformation(&request) Self.setupAPIKey(&request, model: model, apiKey: apiKey) Self.setupExtraHeaderFields(&request, model: model) + requestModifier?(&request) let (result, response) = try await URLSession.shared.bytes(for: request) guard let response = response as? HTTPURLResponse else { From 17514e3d586037af50e76b87ef31e02731d6b5bc Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 24 Feb 2025 14:46:59 +0800 Subject: [PATCH 13/29] Add GitHub Copilot auth token fetcher --- .../GitHubCopilotExtension.swift | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift index a556e8ff..f6831e92 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift @@ -20,6 +20,35 @@ public final class GitHubCopilotExtension: BuiltinExtension { extensionUsage.isSuggestionServiceInUse || extensionUsage.isChatServiceInUse } + public struct AuthToken: Codable { + public let user: String + public let oauth_token: String + public let githubAppId: String + } + + public static var authToken: AuthToken? { + guard let urls = try? GitHubCopilotBaseService.createFoldersIfNeeded() + else { return nil } + let path = urls.supportURL + .appendingPathComponent("undefined") + .appendingPathComponent(".config") + .appendingPathComponent("github-copilot") + .appendingPathComponent("apps.json").path + guard FileManager.default.fileExists(atPath: path) else { return nil } + + do { + let data = try Data(contentsOf: URL(fileURLWithPath: path)) + let json = try JSONSerialization + .jsonObject(with: data, options: []) as? [String: [String: String]] + guard let firstEntry = json?.values.first else { return nil } + let jsonData = try JSONSerialization.data(withJSONObject: firstEntry, options: []) + return try JSONDecoder().decode(AuthToken.self, from: jsonData) + } catch { + Logger.gitHubCopilot.error(error.localizedDescription) + return nil + } + } + let workspacePool: WorkspacePool let serviceLocator: ServiceLocatorType @@ -139,7 +168,6 @@ protocol ServiceLocatorType { class ServiceLocator: ServiceLocatorType { let workspacePool: WorkspacePool - init(workspacePool: WorkspacePool) { self.workspacePool = workspacePool } From 271000062fb8e66f7e737752ec9ea79da4a8df43 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 24 Feb 2025 14:51:11 +0800 Subject: [PATCH 14/29] Update name --- Core/Sources/ChatGPTChatTab/ChatContextMenu.swift | 2 +- .../FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift index d4291d2f..9114a5dd 100644 --- a/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift +++ b/Core/Sources/ChatGPTChatTab/ChatContextMenu.swift @@ -72,7 +72,7 @@ struct ChatContextMenu: View { var chatModel: some View { let allModels = chatModels + [.init( id: "com.github.copilot", - name: "GitHub Copilot as chat model", + name: "GitHub Copilot Language Server", format: .openAI, info: .init() )] diff --git a/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift b/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift index b78d00a4..93316505 100644 --- a/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift +++ b/Core/Sources/HostApp/FeatureSettings/Chat/ChatSettingsGeneralSectionView.swift @@ -155,7 +155,7 @@ struct ChatSettingsGeneralSectionView: View { ) { let allModels = settings.chatModels + [.init( id: "com.github.copilot", - name: "GitHub Copilot as chat model", + name: "GitHub Copilot Language Server", format: .openAI, info: .init() )] From d8f95d287ffa6167f579e4ab93f9052a0792b658 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 24 Feb 2025 16:24:37 +0800 Subject: [PATCH 15/29] Add method to get GitHubCopilot tokens --- Tool/Package.swift | 1 + .../GitHubCopilotExtension.swift | 127 ++++++++++++++---- .../LanguageServer/GitHubCopilotService.swift | 3 + 3 files changed, 102 insertions(+), 29 deletions(-) diff --git a/Tool/Package.swift b/Tool/Package.swift index b5f93686..7d356d08 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -453,6 +453,7 @@ let package = Package( "Keychain", "BuiltinExtension", "ChatBasic", + "GitHubCopilotService", .product(name: "JSONRPC", package: "JSONRPC"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), .product(name: "GoogleGenerativeAI", package: "generative-ai-swift"), diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift index f6831e92..eaa64a33 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift @@ -20,35 +20,6 @@ public final class GitHubCopilotExtension: BuiltinExtension { extensionUsage.isSuggestionServiceInUse || extensionUsage.isChatServiceInUse } - public struct AuthToken: Codable { - public let user: String - public let oauth_token: String - public let githubAppId: String - } - - public static var authToken: AuthToken? { - guard let urls = try? GitHubCopilotBaseService.createFoldersIfNeeded() - else { return nil } - let path = urls.supportURL - .appendingPathComponent("undefined") - .appendingPathComponent(".config") - .appendingPathComponent("github-copilot") - .appendingPathComponent("apps.json").path - guard FileManager.default.fileExists(atPath: path) else { return nil } - - do { - let data = try Data(contentsOf: URL(fileURLWithPath: path)) - let json = try JSONSerialization - .jsonObject(with: data, options: []) as? [String: [String: String]] - guard let firstEntry = json?.values.first else { return nil } - let jsonData = try JSONSerialization.data(withJSONObject: firstEntry, options: []) - return try JSONDecoder().decode(AuthToken.self, from: jsonData) - } catch { - Logger.gitHubCopilot.error(error.localizedDescription) - return nil - } - } - let workspacePool: WorkspacePool let serviceLocator: ServiceLocatorType @@ -180,3 +151,101 @@ class ServiceLocator: ServiceLocatorType { } } +extension GitHubCopilotExtension { + public struct Token: Codable { +// let codesearch: Bool + public let individual: Bool + public let endpoints: Endpoints + public let chat_enabled: Bool + public let sku: String +// public let copilotignore_enabled: Bool +// public let limited_user_quotas: String? + public let tracking_id: String +// public let xcode: Bool +// public let limited_user_reset_date: String? +// public let telemetry: String +// public let prompt_8k: Bool + public let token: String +// public let nes_enabled: Bool +// public let vsc_electron_fetcher_v2: Bool +// public let code_review_enabled: Bool +// public let annotations_enabled: Bool +// public let chat_jetbrains_enabled: Bool +// public let xcode_chat: Bool +// public let refresh_in: Int +// public let snippy_load_test_enabled: Bool +// public let trigger_completion_after_accept: Bool + public let expires_at: Int +// public let public_suggestions: String +// public let code_quote_enabled: Bool + + public struct Endpoints: Codable { + public let api: String + public let proxy: String + public let telemetry: String + public let origin_tracker: String + } + } + + struct AuthInfo: Codable { + public let user: String + public let oauth_token: String + public let githubAppId: String + } + + static var authInfo: AuthInfo? { + guard let urls = try? GitHubCopilotBaseService.createFoldersIfNeeded() + else { return nil } + let path = urls.supportURL + .appendingPathComponent("undefined") + .appendingPathComponent(".config") + .appendingPathComponent("github-copilot") + .appendingPathComponent("apps.json").path + guard FileManager.default.fileExists(atPath: path) else { return nil } + + do { + let data = try Data(contentsOf: URL(fileURLWithPath: path)) + let json = try JSONSerialization + .jsonObject(with: data, options: []) as? [String: [String: String]] + guard let firstEntry = json?.values.first else { return nil } + let jsonData = try JSONSerialization.data(withJSONObject: firstEntry, options: []) + return try JSONDecoder().decode(AuthInfo.self, from: jsonData) + } catch { + Logger.gitHubCopilot.error(error.localizedDescription) + return nil + } + } + + @MainActor + static var cachedToken: Token? + + public static func fetchToken() async throws -> Token { + guard let authToken = authInfo?.oauth_token + else { throw GitHubCopilotError.notLoggedIn } + + let oldToken = await MainActor.run { cachedToken } + if let oldToken { + let expiresAt = Date(timeIntervalSince1970: TimeInterval(oldToken.expires_at)) + if expiresAt > Date() { + return oldToken + } + } + + let url = URL(string: "https://api.github.com/copilot_internal/v2/token")! + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("token \(authToken)", forHTTPHeaderField: "authorization") + request.setValue("unknown-editor/0", forHTTPHeaderField: "editor-version") + request.setValue("unknown-editor-plugin/0", forHTTPHeaderField: "editor-plugin-version") + request.setValue("1.236.0", forHTTPHeaderField: "copilot-language-server-version") + request.setValue("GithubCopilot/1.236.0", forHTTPHeaderField: "user-agent") + request.setValue("*/*", forHTTPHeaderField: "accept") + request.setValue("gzip,deflate,br", forHTTPHeaderField: "accept-encoding") + + let (data, _) = try await URLSession.shared.data(for: request) + let newToken = try JSONDecoder().decode(Token.self, from: data) + await MainActor.run { cachedToken = newToken } + return newToken + } +} + diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift index 590cbbe4..7979c76e 100644 --- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift +++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotService.swift @@ -53,6 +53,7 @@ extension GitHubCopilotLSP { } enum GitHubCopilotError: Error, LocalizedError { + case notLoggedIn case languageServerNotInstalled case languageServerError(ServerError) case failedToInstallStartScript @@ -60,6 +61,8 @@ enum GitHubCopilotError: Error, LocalizedError { var errorDescription: String? { switch self { + case .notLoggedIn: + return "Not logged in." case .languageServerNotInstalled: return "Language server is not installed." case .failedToInstallStartScript: From e6dee6b24aeaf1634edab8fe9c29a6e2505900d0 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 24 Feb 2025 16:24:48 +0800 Subject: [PATCH 16/29] Add header value parser --- .../APIs/ChatCompletionsAPIDefinition.swift | 17 +++ .../APIs/EmbeddingAPIDefinitions.swift | 17 +++ .../APIs/OlamaChatCompletionsService.swift | 8 +- .../APIs/OpenAIChatCompletionsService.swift | 8 +- .../APIs/OpenAIEmbeddingService.swift | 9 +- .../OpenAIService/HeaderValueParser.swift | 101 ++++++++++++++++++ 6 files changed, 140 insertions(+), 20 deletions(-) create mode 100644 Tool/Sources/OpenAIService/HeaderValueParser.swift diff --git a/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIDefinition.swift b/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIDefinition.swift index 5c25aaa2..87a5e73e 100644 --- a/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIDefinition.swift +++ b/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIDefinition.swift @@ -172,6 +172,23 @@ protocol ChatCompletionsStreamAPI { func callAsFunction() async throws -> AsyncThrowingStream } +extension ChatCompletionsStreamAPI { + static func setupExtraHeaderFields( + _ request: inout URLRequest, + model: ChatModel, + apiKey: String + ) async { + let parser = HeaderValueParser() + 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) + ) + request.setValue(value, forHTTPHeaderField: field.key) + } + } +} + extension AsyncSequence { func toStream() -> AsyncThrowingStream { AsyncThrowingStream { continuation in diff --git a/Tool/Sources/OpenAIService/APIs/EmbeddingAPIDefinitions.swift b/Tool/Sources/OpenAIService/APIs/EmbeddingAPIDefinitions.swift index 2d099a0b..6a63ee7b 100644 --- a/Tool/Sources/OpenAIService/APIs/EmbeddingAPIDefinitions.swift +++ b/Tool/Sources/OpenAIService/APIs/EmbeddingAPIDefinitions.swift @@ -9,6 +9,23 @@ protocol EmbeddingAPI { func embed(tokens: [[Int]]) async throws -> EmbeddingResponse } +extension EmbeddingAPI { + static func setupExtraHeaderFields( + _ request: inout URLRequest, + model: EmbeddingModel, + apiKey: String + ) async { + let parser = HeaderValueParser() + 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) + ) + request.setValue(value, forHTTPHeaderField: field.key) + } + } +} + public struct EmbeddingResponse: Decodable { public struct Object: Decodable { public var embedding: [Float] diff --git a/Tool/Sources/OpenAIService/APIs/OlamaChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OlamaChatCompletionsService.swift index 012dd410..9ac6e0dd 100644 --- a/Tool/Sources/OpenAIService/APIs/OlamaChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/OlamaChatCompletionsService.swift @@ -64,9 +64,7 @@ extension OllamaChatCompletionsService: ChatCompletionsAPI { request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") } - for field in model.info.customHeaderInfo.headers where !field.key.isEmpty { - request.setValue(field.value, forHTTPHeaderField: field.key) - } + await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey) let (result, response) = try await URLSession.shared.data(for: request) @@ -149,9 +147,7 @@ extension OllamaChatCompletionsService: ChatCompletionsStreamAPI { request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") } - for field in model.info.customHeaderInfo.headers where !field.key.isEmpty { - request.setValue(field.value, forHTTPHeaderField: field.key) - } + await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey) let (result, response) = try await URLSession.shared.bytes(for: request) diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift index 25b96bfc..134d2612 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift @@ -327,7 +327,7 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI Self.setupAppInformation(&request) Self.setupAPIKey(&request, model: model, apiKey: apiKey) - Self.setupExtraHeaderFields(&request, model: model) + await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey) requestModifier?(&request) let (result, response) = try await URLSession.shared.bytes(for: request) @@ -473,12 +473,6 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI } } } - - static func setupExtraHeaderFields(_ request: inout URLRequest, model: ChatModel) { - for field in model.info.customHeaderInfo.headers where !field.key.isEmpty { - request.setValue(field.value, forHTTPHeaderField: field.key) - } - } } extension OpenAIChatCompletionsService.ResponseBody { diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift index 80551a1b..a56de6c7 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift @@ -41,6 +41,7 @@ struct OpenAIEmbeddingService: EmbeddingAPI { Self.setupAppInformation(&request) Self.setupAPIKey(&request, model: model, apiKey: apiKey) + await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey) let (result, response) = try await URLSession.shared.data(for: request) guard let response = response as? HTTPURLResponse else { @@ -80,7 +81,7 @@ struct OpenAIEmbeddingService: EmbeddingAPI { Self.setupAppInformation(&request) Self.setupAPIKey(&request, model: model, apiKey: apiKey) - Self.setupExtraHeaderFields(&request, model: model) + await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey) let (result, response) = try await URLSession.shared.data(for: request) guard let response = response as? HTTPURLResponse else { @@ -140,11 +141,5 @@ struct OpenAIEmbeddingService: EmbeddingAPI { } } } - - static func setupExtraHeaderFields(_ request: inout URLRequest, model: EmbeddingModel) { - for field in model.info.customHeaderInfo.headers where !field.key.isEmpty { - request.setValue(field.value, forHTTPHeaderField: field.key) - } - } } diff --git a/Tool/Sources/OpenAIService/HeaderValueParser.swift b/Tool/Sources/OpenAIService/HeaderValueParser.swift new file mode 100644 index 00000000..4ba74f33 --- /dev/null +++ b/Tool/Sources/OpenAIService/HeaderValueParser.swift @@ -0,0 +1,101 @@ +import Foundation +import GitHubCopilotService +import Logger +import Terminal + +public struct HeaderValueParser { + public enum Placeholder: String { + case gitHubCopilotOBearerToken = "github_copilot_bearer_token" + case apiKey = "api_key" + case modelName = "model_name" + } + + public struct Context { + public var modelName: String + public var apiKey: String + public var gitHubCopilotToken: () async -> GitHubCopilotExtension.Token? + public var shellEnvironmentVariable: (_ key: String) async -> String? + + public init( + modelName: String, + apiKey: String, + gitHubCopilotToken: (() async -> GitHubCopilotExtension.Token?)? = nil, + shellEnvironmentVariable: ((_: String) async -> String?)? = nil + ) { + self.modelName = modelName + self.apiKey = apiKey + self.gitHubCopilotToken = gitHubCopilotToken ?? { + try? await GitHubCopilotExtension.fetchToken() + } + self.shellEnvironmentVariable = shellEnvironmentVariable ?? { p in + let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/bash" + let terminal = Terminal() + return try? await terminal.runCommand( + shell, + arguments: ["-i", "-l", "-c", "echo $\(p)"], + environment: [:] + ) + } + } + } + + public init() {} + + /// Replace `{{PlaceHolder}}` with exact values. + public func parse(_ value: String, context: Context) async -> String { + var parsedValue = value + let placeholderRanges = findPlaceholderRanges(in: parsedValue) + + for (range, placeholderText) in placeholderRanges.reversed() { + let cleanPlaceholder = placeholderText + .trimmingCharacters(in: CharacterSet(charactersIn: "{}")) + + var replacement: String? + if let knownPlaceholder = Placeholder(rawValue: cleanPlaceholder) { + async let token = context.gitHubCopilotToken() + switch knownPlaceholder { + case .gitHubCopilotOBearerToken: + replacement = await token?.token + case .apiKey: + replacement = context.apiKey + case .modelName: + replacement = context.modelName + } + } else { + replacement = await context.shellEnvironmentVariable(cleanPlaceholder) + } + + if let replacement { + parsedValue.replaceSubrange(range, with: replacement) + } else { + parsedValue.replaceSubrange(range, with: "none") + } + } + + return parsedValue + } + + private func findPlaceholderRanges(in string: String) -> [(Range, String)] { + var ranges: [(Range, String)] = [] + let pattern = #"\{\{[^}]+\}\}"# + + do { + let regex = try NSRegularExpression(pattern: pattern) + let matches = regex.matches( + in: string, + range: NSRange(string.startIndex..., in: string) + ) + + for match in matches { + if let range = Range(match.range, in: string) { + ranges.append((range, String(string[range]))) + } + } + } catch { + Logger.service.error("Failed to find placeholders in string: \(string)") + } + + return ranges + } +} + From dca1c0d44cc0f991018a8419e8906466fc91e1e5 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 24 Feb 2025 21:26:07 +0800 Subject: [PATCH 17/29] Support joining split documents --- .../DocumentTransformer/TextSplitter.swift | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift b/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift index 4e467106..91e745dd 100644 --- a/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift +++ b/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift @@ -26,6 +26,9 @@ public extension TextSplitter { for (text, metadata) in zip(texts, metadata) { let chunks = try await split(text: text) for chunk in chunks { + var metadata = metadata + metadata["startUTF16Offset"] = .number(Double(chunk.startUTF16Offset)) + metadata["endUTF16Offset"] = .number(Double(chunk.endUTF16Offset)) let document = Document(pageContent: chunk.text, metadata: metadata) documents.append(document) } @@ -48,6 +51,32 @@ public extension TextSplitter { func transformDocuments(_ documents: [Document]) async throws -> [Document] { return try await splitDocuments(documents) } + + func joinDocuments(_ documents: [Document]) -> Document { + let textChunks: [TextChunk] = documents.compactMap { document in + func extract(_ key: String) -> Int? { + if case let .number(d) = document.metadata[key] { + return Int(d) + } + return nil + } + guard let start = extract("startUTF16Offset"), + let end = extract("endUTF16Offset") + else { return nil } + return TextChunk( + text: document.pageContent, + startUTF16Offset: start, + endUTF16Offset: end + ) + }.sorted(by: { $0.startUTF16Offset < $1.startUTF16Offset }) + let mergedChunks = mergeSplits(textChunks) + let pageContent = mergedChunks.map(\.text).joined() + var metadata = documents.first?.metadata ?? [String: JSONValue]() + metadata["startUTF16Offset"] = nil + metadata["endUTF16Offset"] = nil + + return Document(pageContent: pageContent, metadata: metadata) + } } public struct TextChunk: Equatable { @@ -83,14 +112,14 @@ public extension TextSplitter { let text = (a + b).map(\.text).joined() var l = Int.max var u = 0 - + for chunk in a + b { l = min(l, chunk.startUTF16Offset) u = max(u, chunk.endUTF16Offset) } - + guard l < u else { return nil } - + return .init(text: text, startUTF16Offset: l, endUTF16Offset: u) } From 50270bfe708e404514113c48b935e6d91081f4e8 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 24 Feb 2025 21:33:34 +0800 Subject: [PATCH 18/29] Add new models --- Tool/Sources/Preferences/Types/ChatGPTModel.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Tool/Sources/Preferences/Types/ChatGPTModel.swift b/Tool/Sources/Preferences/Types/ChatGPTModel.swift index 8ac04faf..ae199b75 100644 --- a/Tool/Sources/Preferences/Types/ChatGPTModel.swift +++ b/Tool/Sources/Preferences/Types/ChatGPTModel.swift @@ -19,10 +19,12 @@ public enum ChatGPTModel: String, CaseIterable { case gpt432k0314 = "gpt-4-32k-0314" case gpt432k0613 = "gpt-4-32k-0613" case gpt40125 = "gpt-4-0125-preview" + case o1 = "o1" case o1Preview = "o1-preview" case o1Preview20240912 = "o1-preview-2024-09-12" case o1Mini = "o1-mini" case o1Mini20240912 = "o1-mini-2024-09-12" + case o3Mini = "o3-mini" } public extension ChatGPTModel { @@ -68,13 +70,17 @@ public extension ChatGPTModel { return 128_000 case .o1Mini, .o1Mini20240912: return 128_000 + case .o1: + return 200_000 + case .o3Mini: + return 200_000 } } var supportsImages: Bool { switch self { case .gpt4VisionPreview, .gpt4Turbo, .gpt4Turbo20240409, .gpt4o, .gpt4oMini, .o1Preview, - .o1Preview20240912, .o1Mini, .o1Mini20240912: + .o1Preview20240912, .o1Mini, .o1Mini20240912, .o1, .o3Mini: return true default: return false From 5e8d1ec569445fe880d5a9239b1a0609279a39a2 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 24 Feb 2025 21:33:46 +0800 Subject: [PATCH 19/29] Adjust debug UI z index --- Core/Sources/SuggestionWidget/WidgetWindowsController.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index e0e25de4..d2a2b2b3 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -880,7 +880,11 @@ class WidgetWindow: CanBecomeKeyWindow { func widgetLevel(_ addition: Int) -> NSWindow.Level { let minimumWidgetLevel: Int + #if DEBUG + minimumWidgetLevel = NSWindow.Level.floating.rawValue + 1 + #else minimumWidgetLevel = NSWindow.Level.floating.rawValue + #endif return .init(minimumWidgetLevel + addition) } From 7c2c89b65d588f31599ec54416c0baf35699ff60 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 24 Feb 2025 22:37:57 +0800 Subject: [PATCH 20/29] Support github copilot chat direct call --- .../ChatModelManagement/ChatModelEdit.swift | 16 ++- .../ChatModelEditView.swift | 59 +++++++++ .../ChatModelManagement.swift | 1 + Tool/Sources/AIModel/ChatModel.swift | 3 + .../GitHubCopilotExtension.swift | 22 ++-- .../APIs/ChatCompletionsAPIBuilder.swift | 11 ++ .../GitHubCopilotChatCompletionsService.swift | 112 ++++++++++++++++++ .../APIs/OpenAIChatCompletionsService.swift | 2 + .../OpenAIService/ChatGPTService.swift | 2 +- 9 files changed, 218 insertions(+), 10 deletions(-) create mode 100644 Tool/Sources/OpenAIService/APIs/GitHubCopilotChatCompletionsService.swift diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift index 831fc08c..d47eb3fb 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift @@ -56,12 +56,13 @@ struct ChatModelEdit { case googleAI case ollama case claude + case gitHubCopilot case openAICompatible case deepSeekOpenAICompatible case openRouterOpenAICompatible case grokOpenAICompatible case mistralOpenAICompatible - + init(_ format: ChatModel.Format) { switch format { case .openAI: @@ -76,6 +77,8 @@ struct ChatModelEdit { self = .claude case .openAICompatible: self = .openAICompatible + case .gitHubCopilot: + self = .gitHubCopilot } } } @@ -195,6 +198,13 @@ struct ChatModelEdit { state.suggestedMaxTokens = nil } return .none + case .gitHubCopilot: + if let knownModel = AvailableGitHubCopilotModel(rawValue: state.modelName) { + state.suggestedMaxTokens = knownModel.contextWindow + } else { + state.suggestedMaxTokens = nil + } + return .none default: state.suggestedMaxTokens = nil return .none @@ -212,6 +222,8 @@ struct ChatModelEdit { state.format = .ollama case .claude: state.format = .claude + case .gitHubCopilot: + state.format = .gitHubCopilot case .openAICompatible: state.format = .openAICompatible case .deepSeekOpenAICompatible: @@ -272,7 +284,7 @@ extension ChatModel { switch state.format { case .googleAI, .ollama, .claude: return false - case .azureOpenAI, .openAI, .openAICompatible: + case .azureOpenAI, .openAI, .openAICompatible, .gitHubCopilot: return state.supportsFunctionCalling } }(), diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift index 2a300f08..7949ea0e 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift @@ -29,6 +29,8 @@ struct ChatModelEditView: View { OllamaForm(store: store) case .claude: ClaudeForm(store: store) + case .gitHubCopilot: + GitHubCopilotForm(store: store) } } .padding() @@ -108,6 +110,8 @@ struct ChatModelEditView: View { Text("Ollama") case .claude: Text("Claude") + case .gitHubCopilot: + Text("GitHub Copilot") case .deepSeekOpenAICompatible: Text("DeepSeek (OpenAI Compatible)") case .openRouterOpenAICompatible: @@ -464,6 +468,61 @@ struct ChatModelEditView: View { } } } + + struct GitHubCopilotForm: View { + @Perception.Bindable var store: StoreOf + @State var isEditingCustomHeader = false + + var body: some View { + WithPerceptionTracking { + TextField("Model Name", text: $store.modelName) + .overlay(alignment: .trailing) { + Picker( + "", + selection: $store.modelName, + content: { + if AvailableGitHubCopilotModel(rawValue: store.modelName) == nil { + Text("Custom Model").tag(store.modelName) + } + ForEach(AvailableGitHubCopilotModel.allCases, id: \.self) { model in + Text(model.rawValue).tag(model.rawValue) + } + } + ) + .frame(width: 20) + } + + MaxTokensTextField(store: store) + SupportsFunctionCallingToggle(store: store) + + Toggle(isOn: $store.enforceMessageOrder) { + Text("Enforce message order to be user/assistant alternated") + } + + Toggle(isOn: $store.openAICompatibleSupportsMultipartMessageContent) { + Text("Support multi-part message content") + } + + Button("Custom Headers") { + isEditingCustomHeader.toggle() + } + + VStack(alignment: .leading, spacing: 8) { + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " Please login in the GitHub Copilot settings to use the model." + ) + + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " This will call the APIs directly, which may not be allowed by GitHub. But it's used in other popular apps like Zed." + ) + } + .dynamicHeightTextInFormWorkaround() + .padding(.vertical) + }.sheet(isPresented: $isEditingCustomHeader) { + CustomHeaderSettingsView(headers: $store.customHeaders) + } + } + } } #Preview("OpenAI") { diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift index 4dc46630..64eadd57 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelManagement.swift @@ -13,6 +13,7 @@ extension ChatModel: ManageableAIModel { case .googleAI: return "Google Generative AI" case .ollama: return "Ollama" case .claude: return "Claude" + case .gitHubCopilot: return "GitHub Copilot" } } diff --git a/Tool/Sources/AIModel/ChatModel.swift b/Tool/Sources/AIModel/ChatModel.swift index c98658c7..a23a36b4 100644 --- a/Tool/Sources/AIModel/ChatModel.swift +++ b/Tool/Sources/AIModel/ChatModel.swift @@ -23,6 +23,7 @@ public struct ChatModel: Codable, Equatable, Identifiable { case googleAI case ollama case claude + case gitHubCopilot } public struct Info: Codable, Equatable { @@ -178,6 +179,8 @@ public struct ChatModel: Codable, Equatable, Identifiable { let baseURL = info.baseURL if baseURL.isEmpty { return "https://api.anthropic.com/v1/messages" } return "\(baseURL)/v1/messages" + case .gitHubCopilot: + return "https://api.githubcopilot.com/chat/completions" } } } diff --git a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift index eaa64a33..f18ac2be 100644 --- a/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift +++ b/Tool/Sources/GitHubCopilotService/GitHubCopilotExtension.swift @@ -157,10 +157,10 @@ extension GitHubCopilotExtension { public let individual: Bool public let endpoints: Endpoints public let chat_enabled: Bool - public let sku: String +// public let sku: String // public let copilotignore_enabled: Bool // public let limited_user_quotas: String? - public let tracking_id: String +// public let tracking_id: String // public let xcode: Bool // public let limited_user_reset_date: String? // public let telemetry: String @@ -183,7 +183,7 @@ extension GitHubCopilotExtension { public let api: String public let proxy: String public let telemetry: String - public let origin_tracker: String +// public let origin-tracker: String } } @@ -242,10 +242,18 @@ extension GitHubCopilotExtension { request.setValue("*/*", forHTTPHeaderField: "accept") request.setValue("gzip,deflate,br", forHTTPHeaderField: "accept-encoding") - let (data, _) = try await URLSession.shared.data(for: request) - let newToken = try JSONDecoder().decode(Token.self, from: data) - await MainActor.run { cachedToken = newToken } - return newToken + do { + let (data, _) = try await URLSession.shared.data(for: request) + if let jsonString = String(data: data, encoding: .utf8) { + print(jsonString) + } + let newToken = try JSONDecoder().decode(Token.self, from: data) + await MainActor.run { cachedToken = newToken } + return newToken + } catch { + Logger.service.error(error.localizedDescription) + throw error + } } } diff --git a/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIBuilder.swift b/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIBuilder.swift index f114b32d..8f8e3feb 100644 --- a/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIBuilder.swift +++ b/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIBuilder.swift @@ -62,6 +62,11 @@ struct DefaultChatCompletionsAPIBuilder: ChatCompletionsAPIBuilder { endpoint: endpoint, requestBody: requestBody ) + case .gitHubCopilot: + return GitHubCopilotChatCompletionsService( + model: model, + requestBody: requestBody + ) } } @@ -107,6 +112,11 @@ struct DefaultChatCompletionsAPIBuilder: ChatCompletionsAPIBuilder { endpoint: endpoint, requestBody: requestBody ) + case .gitHubCopilot: + return GitHubCopilotChatCompletionsService( + model: model, + requestBody: requestBody + ) } } } @@ -121,3 +131,4 @@ extension DependencyValues { set { self[ChatCompletionsAPIBuilderDependencyKey.self] = newValue } } } + diff --git a/Tool/Sources/OpenAIService/APIs/GitHubCopilotChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/GitHubCopilotChatCompletionsService.swift new file mode 100644 index 00000000..d9b2a51b --- /dev/null +++ b/Tool/Sources/OpenAIService/APIs/GitHubCopilotChatCompletionsService.swift @@ -0,0 +1,112 @@ +import AIModel +import AsyncAlgorithms +import ChatBasic +import Foundation +import GitHubCopilotService +import Logger +import Preferences + +public enum AvailableGitHubCopilotModel: String, CaseIterable { + case claude35sonnet = "claude-3.5-sonnet" + case o1Mini = "o1-mini" + case o1 = "o1" + case gpt4Turbo = "gpt-4-turbo" + case gpt4oMini = "gpt-4o-mini" + case gpt4o = "gpt-4o" + case gpt4 = "gpt-4" + case gpt35Turbo = "gpt-3.5-turbo" + + public var contextWindow: Int { + switch self { + case .claude35sonnet: + return 200_000 + case .o1Mini: + return 128_000 + case .o1: + return 128_000 + case .gpt4Turbo: + return 128_000 + case .gpt4oMini: + return 128_000 + case .gpt4o: + return 128_000 + case .gpt4: + return 32_768 + case .gpt35Turbo: + return 16_384 + } + } +} + +/// Looks like it's used in many other popular repositories so maybe it's safe. +actor GitHubCopilotChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI { + + let chatModel: ChatModel + let requestBody: ChatCompletionsRequestBody + + init( + model: ChatModel, + requestBody: ChatCompletionsRequestBody + ) { + var model = model + model.format = .openAICompatible + chatModel = model + self.requestBody = requestBody + } + + func callAsFunction() async throws + -> AsyncThrowingStream + { + let service = try await buildService() + return try await service() + } + + func callAsFunction() async throws -> ChatCompletionResponseBody { + let service = try await buildService() + return try await service() + } + + private func buildService() async throws -> OpenAIChatCompletionsService { + let token = try await GitHubCopilotExtension.fetchToken() + + guard let endpoint = URL(string: token.endpoints.api + "/chat/completions") else { + throw ChatGPTServiceError.endpointIncorrect + } + + return OpenAIChatCompletionsService( + apiKey: token.token, + model: chatModel, + endpoint: endpoint, + requestBody: requestBody + ) { request in + +// POST /chat/completions HTTP/2 +// :authority: api.individual.githubcopilot.com +// authorization: Bearer * +// x-request-id: * +// openai-organization: github-copilot +// vscode-sessionid: * +// vscode-machineid: * +// editor-version: vscode/1.89.1 +// editor-plugin-version: Copilot for Xcode/0.35.5 +// copilot-language-server-version: 1.236.0 +// x-github-api-version: 2023-07-07 +// openai-intent: conversation-panel +// content-type: application/json +// user-agent: GithubCopilot/1.236.0 +// content-length: 9061 +// accept: */* +// accept-encoding: gzip,deflate,br + + request.setValue( + "Copilot for Xcode/\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown")", + forHTTPHeaderField: "Editor-Version" + ) + request.setValue("Bearer \(token.token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("vscode-chat", forHTTPHeaderField: "Copilot-Integration-Id") + request.setValue("2023-07-07", forHTTPHeaderField: "X-Github-Api-Version") + } + } +} + diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift index 134d2612..c45c2944 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift @@ -464,6 +464,8 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") case .azureOpenAI: request.setValue(apiKey, forHTTPHeaderField: "api-key") + case .gitHubCopilot: + break case .googleAI: assertionFailure("Unsupported") case .ollama: diff --git a/Tool/Sources/OpenAIService/ChatGPTService.swift b/Tool/Sources/OpenAIService/ChatGPTService.swift index 986a7396..25ba3929 100644 --- a/Tool/Sources/OpenAIService/ChatGPTService.swift +++ b/Tool/Sources/OpenAIService/ChatGPTService.swift @@ -540,7 +540,7 @@ extension ChatGPTService { stream: Bool ) -> ChatCompletionsRequestBody { let serviceSupportsFunctionCalling = switch model.format { - case .openAI, .openAICompatible, .azureOpenAI: + case .openAI, .openAICompatible, .azureOpenAI, .gitHubCopilot: model.info.supportsFunctionCalling case .ollama, .googleAI, .claude: false From 5f0743166cf83a941192a48fd2c5bd699f379314 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 24 Feb 2025 22:52:35 +0800 Subject: [PATCH 21/29] Support embedding with GitHub Copilot --- .../EmbeddingModelEdit.swift | 5 ++ .../EmbeddingModelEditView.swift | 51 +++++++++++++ .../EmbeddingModelManagement.swift | 1 + Tool/Sources/AIModel/EmbeddingModel.swift | 3 + .../APIs/GitHubCopilotEmbeddingService.swift | 72 +++++++++++++++++++ .../APIs/OpenAIEmbeddingService.swift | 5 ++ .../OpenAIService/EmbeddingService.swift | 12 ++++ 7 files changed, 149 insertions(+) create mode 100644 Tool/Sources/OpenAIService/APIs/GitHubCopilotEmbeddingService.swift diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift index 29efe52d..f057be21 100644 --- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEdit.swift @@ -50,6 +50,7 @@ struct EmbeddingModelEdit { case openAI case azureOpenAI case ollama + case gitHubCopilot case openAICompatible case mistralOpenAICompatible case voyageAIOpenAICompatible @@ -64,6 +65,8 @@ struct EmbeddingModelEdit { self = .ollama case .openAICompatible: self = .openAICompatible + case .gitHubCopilot: + self = .gitHubCopilot } } } @@ -189,6 +192,8 @@ struct EmbeddingModelEdit { state.format = .ollama case .openAICompatible: state.format = .openAICompatible + case .gitHubCopilot: + state.format = .gitHubCopilot case .mistralOpenAICompatible: state.format = .openAICompatible state.baseURLSelection.baseURL = "https://api.mistral.ai" diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift index a0bda5cd..46f4effd 100644 --- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelEditView.swift @@ -24,6 +24,8 @@ struct EmbeddingModelEditView: View { OpenAICompatibleForm(store: store) case .ollama: OllamaForm(store: store) + case .gitHubCopilot: + GitHubCopilotForm(store: store) } } .padding() @@ -103,6 +105,8 @@ struct EmbeddingModelEditView: View { Text("Mistral (OpenAI Compatible)") case .voyageAIOpenAICompatible: Text("Voyage (OpenAI Compatible)") + case .gitHubCopilot: + Text("GitHub Copilot") } } }, @@ -383,6 +387,53 @@ struct EmbeddingModelEditView: View { } } } + + struct GitHubCopilotForm: View { + @Perception.Bindable var store: StoreOf + @State var isEditingCustomHeader = false + + var body: some View { + WithPerceptionTracking { + TextField("Model Name", text: $store.modelName) + .overlay(alignment: .trailing) { + Picker( + "", + selection: $store.modelName, + content: { + if OpenAIEmbeddingModel(rawValue: store.modelName) == nil { + Text("Custom Model").tag(store.modelName) + } + ForEach(OpenAIEmbeddingModel.allCases, id: \.self) { model in + Text(model.rawValue).tag(model.rawValue) + } + } + ) + .frame(width: 20) + } + + MaxTokensTextField(store: store) + DimensionsTextField(store: store) + + Button("Custom Headers") { + isEditingCustomHeader.toggle() + } + + VStack(alignment: .leading, spacing: 8) { + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " Please login in the GitHub Copilot settings to use the model." + ) + + Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( + " This will call the APIs directly, which may not be allowed by GitHub. But it's used in other popular apps like Zed." + ) + } + .dynamicHeightTextInFormWorkaround() + .padding(.vertical) + }.sheet(isPresented: $isEditingCustomHeader) { + CustomHeaderSettingsView(headers: $store.customHeaders) + } + } + } } class EmbeddingModelManagementView_Editing_Previews: PreviewProvider { diff --git a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift index 294ca401..156f58ac 100644 --- a/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift +++ b/Core/Sources/HostApp/AccountSettings/EmbeddingModelManagement/EmbeddingModelManagement.swift @@ -11,6 +11,7 @@ extension EmbeddingModel: ManageableAIModel { case .azureOpenAI: return "Azure OpenAI" case .openAICompatible: return "OpenAI Compatible" case .ollama: return "Ollama" + case .gitHubCopilot: return "GitHub Copilot" } } diff --git a/Tool/Sources/AIModel/EmbeddingModel.swift b/Tool/Sources/AIModel/EmbeddingModel.swift index ad77cd9a..4e192dda 100644 --- a/Tool/Sources/AIModel/EmbeddingModel.swift +++ b/Tool/Sources/AIModel/EmbeddingModel.swift @@ -21,6 +21,7 @@ public struct EmbeddingModel: Codable, Equatable, Identifiable { case azureOpenAI case openAICompatible case ollama + case gitHubCopilot } public struct Info: Codable, Equatable { @@ -92,6 +93,8 @@ public struct EmbeddingModel: Codable, Equatable, Identifiable { let baseURL = info.baseURL if baseURL.isEmpty { return "http://localhost:11434/api/embeddings" } return "\(baseURL)/api/embeddings" + case .gitHubCopilot: + return "https://api.githubcopilot.com/embeddings" } } } diff --git a/Tool/Sources/OpenAIService/APIs/GitHubCopilotEmbeddingService.swift b/Tool/Sources/OpenAIService/APIs/GitHubCopilotEmbeddingService.swift new file mode 100644 index 00000000..627694a4 --- /dev/null +++ b/Tool/Sources/OpenAIService/APIs/GitHubCopilotEmbeddingService.swift @@ -0,0 +1,72 @@ +import AIModel +import AsyncAlgorithms +import ChatBasic +import Foundation +import GitHubCopilotService +import Logger +import Preferences + +/// Looks like it's used in many other popular repositories so maybe it's safe. +actor GitHubCopilotEmbeddingService: EmbeddingAPI { + let chatModel: EmbeddingModel + + init(model: EmbeddingModel) { + var model = model + model.format = .openAICompatible + chatModel = model + } + + func embed(text: String) async throws -> EmbeddingResponse { + let service = try await buildService() + return try await service.embed(text: text) + } + + func embed(texts: [String]) async throws -> EmbeddingResponse { + let service = try await buildService() + return try await service.embed(texts: texts) + } + + func embed(tokens: [[Int]]) async throws -> EmbeddingResponse { + let service = try await buildService() + return try await service.embed(tokens: tokens) + } + + private func buildService() async throws -> OpenAIEmbeddingService { + let token = try await GitHubCopilotExtension.fetchToken() + + return OpenAIEmbeddingService( + apiKey: token.token, + model: chatModel, + endpoint: token.endpoints.api + "/embeddings" + ) { request in + +// POST /chat/completions HTTP/2 +// :authority: api.individual.githubcopilot.com +// authorization: Bearer * +// x-request-id: * +// openai-organization: github-copilot +// vscode-sessionid: * +// vscode-machineid: * +// editor-version: vscode/1.89.1 +// editor-plugin-version: Copilot for Xcode/0.35.5 +// copilot-language-server-version: 1.236.0 +// x-github-api-version: 2023-07-07 +// openai-intent: conversation-panel +// content-type: application/json +// user-agent: GithubCopilot/1.236.0 +// content-length: 9061 +// accept: */* +// accept-encoding: gzip,deflate,br + + request.setValue( + "Copilot for Xcode/\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown")", + forHTTPHeaderField: "Editor-Version" + ) + request.setValue("Bearer \(token.token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("vscode-chat", forHTTPHeaderField: "Copilot-Integration-Id") + request.setValue("2023-07-07", forHTTPHeaderField: "X-Github-Api-Version") + } + } +} + diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift index a56de6c7..f6edf3b7 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAIEmbeddingService.swift @@ -16,6 +16,7 @@ struct OpenAIEmbeddingService: EmbeddingAPI { let apiKey: String let model: EmbeddingModel let endpoint: String + var requestModifier: ((inout URLRequest) -> Void)? = nil public func embed(text: String) async throws -> EmbeddingResponse { return try await embed(texts: [text]) @@ -42,6 +43,7 @@ struct OpenAIEmbeddingService: EmbeddingAPI { Self.setupAppInformation(&request) Self.setupAPIKey(&request, model: model, apiKey: apiKey) await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey) + requestModifier?(&request) let (result, response) = try await URLSession.shared.data(for: request) guard let response = response as? HTTPURLResponse else { @@ -82,6 +84,7 @@ struct OpenAIEmbeddingService: EmbeddingAPI { Self.setupAppInformation(&request) Self.setupAPIKey(&request, model: model, apiKey: apiKey) await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey) + requestModifier?(&request) let (result, response) = try await URLSession.shared.data(for: request) guard let response = response as? HTTPURLResponse else { @@ -136,6 +139,8 @@ struct OpenAIEmbeddingService: EmbeddingAPI { request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") case .azureOpenAI: request.setValue(apiKey, forHTTPHeaderField: "api-key") + case .gitHubCopilot: + break case .ollama: assertionFailure("Unsupported") } diff --git a/Tool/Sources/OpenAIService/EmbeddingService.swift b/Tool/Sources/OpenAIService/EmbeddingService.swift index a0d41f74..d0bf1116 100644 --- a/Tool/Sources/OpenAIService/EmbeddingService.swift +++ b/Tool/Sources/OpenAIService/EmbeddingService.swift @@ -27,6 +27,10 @@ public struct EmbeddingService { model: model, endpoint: configuration.endpoint ).embed(text: text) + case .gitHubCopilot: + embeddingResponse = try await GitHubCopilotEmbeddingService( + model: model + ).embed(text: text) } #if DEBUG @@ -59,6 +63,10 @@ public struct EmbeddingService { model: model, endpoint: configuration.endpoint ).embed(texts: text) + case .gitHubCopilot: + embeddingResponse = try await GitHubCopilotEmbeddingService( + model: model + ).embed(texts: text) } #if DEBUG @@ -91,6 +99,10 @@ public struct EmbeddingService { model: model, endpoint: configuration.endpoint ).embed(tokens: tokens) + case .gitHubCopilot: + embeddingResponse = try await GitHubCopilotEmbeddingService( + model: model + ).embed(tokens: tokens) } #if DEBUG From dc2d2fc3417237fdf57b453d29a835016a53678a Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 25 Feb 2025 02:28:44 +0800 Subject: [PATCH 22/29] Fix selected content --- Tool/Sources/SuggestionBasic/EditorInformation.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tool/Sources/SuggestionBasic/EditorInformation.swift b/Tool/Sources/SuggestionBasic/EditorInformation.swift index 8518b8b0..4f848c01 100644 --- a/Tool/Sources/SuggestionBasic/EditorInformation.swift +++ b/Tool/Sources/SuggestionBasic/EditorInformation.swift @@ -103,7 +103,7 @@ public struct EditorInformation { inside range: CursorRange, ignoreColumns: Bool = false ) -> (code: String, lines: [String]) { - guard range.start <= range.end else { return ("", []) } + guard range.start < range.end else { return ("", []) } let rangeLines = lines(in: code, containing: range) if ignoreColumns { From 0051a26af772809afdd95eae1fabbaa59900bd4f Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 25 Feb 2025 02:47:30 +0800 Subject: [PATCH 23/29] Add option requiresBeginWithUserMessage --- .../ChatModelManagement/ChatModelEdit.swift | 7 +++++-- .../ChatModelManagement/ChatModelEditView.swift | 4 ++++ Tool/Sources/AIModel/ChatModel.swift | 6 +++++- .../APIs/OpenAIChatCompletionsService.swift | 16 +++++++++++++++- 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift index d47eb3fb..0f5e2d1f 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift @@ -33,6 +33,7 @@ struct ChatModelEdit { var openAIProjectID: String = "" var customHeaders: [ChatModel.Info.CustomHeaderInfo.HeaderField] = [] var openAICompatibleSupportsMultipartMessageContent = true + var requiresBeginWithUserMessage = false } enum Action: Equatable, BindableAction { @@ -298,7 +299,8 @@ extension ChatModel { openAICompatibleInfo: .init( enforceMessageOrder: state.enforceMessageOrder, supportsMultipartMessageContent: state - .openAICompatibleSupportsMultipartMessageContent + .openAICompatibleSupportsMultipartMessageContent, + requiresBeginWithUserMessage: state.requiresBeginWithUserMessage ), customHeaderInfo: .init(headers: state.customHeaders) ) @@ -325,7 +327,8 @@ extension ChatModel { openAIProjectID: info.openAIInfo.projectID, customHeaders: info.customHeaderInfo.headers, openAICompatibleSupportsMultipartMessageContent: info.openAICompatibleInfo - .supportsMultipartMessageContent + .supportsMultipartMessageContent, + requiresBeginWithUserMessage: info.openAICompatibleInfo.requiresBeginWithUserMessage ) } } diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift index 7949ea0e..4d3941be 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift @@ -340,6 +340,10 @@ struct ChatModelEditView: View { Toggle(isOn: $store.openAICompatibleSupportsMultipartMessageContent) { Text("Support multi-part message content") } + + Toggle(isOn: $store.requiresBeginWithUserMessage) { + Text("Requires the first message to be from the user") + } Button("Custom Headers") { isEditingCustomHeader.toggle() diff --git a/Tool/Sources/AIModel/ChatModel.swift b/Tool/Sources/AIModel/ChatModel.swift index a23a36b4..7ae666e3 100644 --- a/Tool/Sources/AIModel/ChatModel.swift +++ b/Tool/Sources/AIModel/ChatModel.swift @@ -53,13 +53,17 @@ public struct ChatModel: Codable, Equatable, Identifiable { public var enforceMessageOrder: Bool @FallbackDecoding public var supportsMultipartMessageContent: Bool + @FallbackDecoding + public var requiresBeginWithUserMessage: Bool public init( enforceMessageOrder: Bool = false, - supportsMultipartMessageContent: Bool = true + supportsMultipartMessageContent: Bool = true, + requiresBeginWithUserMessage: Bool = false ) { self.enforceMessageOrder = enforceMessageOrder self.supportsMultipartMessageContent = supportsMultipartMessageContent + self.requiresBeginWithUserMessage = requiresBeginWithUserMessage } } diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift index c45c2944..b614f775 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift @@ -307,6 +307,8 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI enforceMessageOrder: model.info.openAICompatibleInfo.enforceMessageOrder, supportsMultipartMessageContent: model.info.openAICompatibleInfo .supportsMultipartMessageContent, + requiresBeginWithUserMessage: model.info.openAICompatibleInfo + .requiresBeginWithUserMessage, canUseTool: model.info.supportsFunctionCalling, supportsImage: model.info.supportsImage, supportsAudio: model.info.supportsAudio @@ -709,6 +711,7 @@ extension OpenAIChatCompletionsService.RequestBody { endpoint: URL, enforceMessageOrder: Bool, supportsMultipartMessageContent: Bool, + requiresBeginWithUserMessage: Bool, canUseTool: Bool, supportsImage: Bool, supportsAudio: Bool @@ -732,10 +735,21 @@ extension OpenAIChatCompletionsService.RequestBody { model = body.model + var body = body + + if requiresBeginWithUserMessage { + let firstUserIndex = body.messages.firstIndex(where: { $0.role == .user }) ?? 0 + let endIndex = firstUserIndex + for i in stride(from: endIndex - 1, to: 0, by: -1) + where i >= 0 && body.messages.endIndex > i + { + body.messages.remove(at: i) + } + } + // Special case for Claude through OpenRouter if endpoint.absoluteString.contains("openrouter.ai"), model.hasPrefix("anthropic/") { - var body = body body.model = model.replacingOccurrences(of: "anthropic/", with: "") let claudeRequestBody = ClaudeChatCompletionsService.RequestBody(body) messages = claudeRequestBody.system.map { From efeafb0c7fceac21bc8fa7466ddaa132ce6c3353 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 25 Feb 2025 03:47:39 +0800 Subject: [PATCH 24/29] Fix selected content --- Tool/Sources/SuggestionBasic/EditorInformation.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tool/Sources/SuggestionBasic/EditorInformation.swift b/Tool/Sources/SuggestionBasic/EditorInformation.swift index 4f848c01..d9979890 100644 --- a/Tool/Sources/SuggestionBasic/EditorInformation.swift +++ b/Tool/Sources/SuggestionBasic/EditorInformation.swift @@ -24,6 +24,7 @@ public struct EditorInformation { public var selectedContent: String { if let range = selections.first { + if range.isEmpty { return "" } let startIndex = min( max(0, range.start.line), lines.endIndex - 1 From addd0d5cd2cc273408cb3269d2a6bb67cf06011b Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 25 Feb 2025 04:03:29 +0800 Subject: [PATCH 25/29] Bump version --- Version.xcconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Version.xcconfig b/Version.xcconfig index 44c36d4a..bc3af414 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,4 +1,4 @@ -APP_VERSION = 0.35.4 -APP_BUILD = 437 +APP_VERSION = 0.35.5 +APP_BUILD = 443 RELEASE_CHANNEL = RELEASE_NUMBER = 1 From 730669ff80d69a6adc376db295733e661ceb7ebf Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 25 Feb 2025 15:14:29 +0800 Subject: [PATCH 26/29] Fix document merging --- .../DocumentTransformer/TextSplitter.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift b/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift index 91e745dd..5880616c 100644 --- a/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift +++ b/Tool/Sources/LangChain/DocumentTransformer/TextSplitter.swift @@ -69,8 +69,17 @@ public extension TextSplitter { endUTF16Offset: end ) }.sorted(by: { $0.startUTF16Offset < $1.startUTF16Offset }) - let mergedChunks = mergeSplits(textChunks) - let pageContent = mergedChunks.map(\.text).joined() + var sumChunk: TextChunk? + for chunk in textChunks { + if let current = sumChunk { + if let merged = current.merged(with: chunk, force: true) { + sumChunk = merged + } + } else { + sumChunk = chunk + } + } + let pageContent = sumChunk?.text ?? "" var metadata = documents.first?.metadata ?? [String: JSONValue]() metadata["startUTF16Offset"] = nil metadata["endUTF16Offset"] = nil From 1acd81825e2eb3e898f1ee9fc2c48d694bd304a4 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 25 Feb 2025 15:36:57 +0800 Subject: [PATCH 27/29] Enable NSAllowsArbitraryLoads --- Copilot for Xcode.xcodeproj/project.pbxproj | 2 ++ Copilot-for-Xcode-Info.plist | 5 +++++ ExtensionService/Info.plist | 5 +++++ 3 files changed, 12 insertions(+) diff --git a/Copilot for Xcode.xcodeproj/project.pbxproj b/Copilot for Xcode.xcodeproj/project.pbxproj index d5f6f3f2..a2e4ba8a 100644 --- a/Copilot for Xcode.xcodeproj/project.pbxproj +++ b/Copilot for Xcode.xcodeproj/project.pbxproj @@ -958,6 +958,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Copilot-for-Xcode-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = ""; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( @@ -991,6 +992,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Copilot-for-Xcode-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = ""; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/Copilot-for-Xcode-Info.plist b/Copilot-for-Xcode-Info.plist index 07a19a85..9f9fdd6e 100644 --- a/Copilot-for-Xcode-Info.plist +++ b/Copilot-for-Xcode-Info.plist @@ -12,6 +12,11 @@ $(EXTENSION_BUNDLE_NAME) HOST_APP_NAME $(HOST_APP_NAME) + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + SUEnableJavaScript YES SUFeedURL diff --git a/ExtensionService/Info.plist b/ExtensionService/Info.plist index 94c867be..9faed878 100644 --- a/ExtensionService/Info.plist +++ b/ExtensionService/Info.plist @@ -12,6 +12,11 @@ $(EXTENSION_BUNDLE_NAME) HOST_APP_NAME $(HOST_APP_NAME) + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + TEAM_ID_PREFIX $(TeamIdentifierPrefix) XPCService From 1b80c9d5ed943854699b4d10955461e41d6619bf Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 25 Feb 2025 15:37:15 +0800 Subject: [PATCH 28/29] Fix configuration override --- .../OpenAIService/Configuration/ChatGPTConfiguration.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift b/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift index 3b0bd896..3f6c0afe 100644 --- a/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift +++ b/Tool/Sources/OpenAIService/Configuration/ChatGPTConfiguration.swift @@ -120,7 +120,7 @@ public class OverridingChatGPTConfiguration: ChatGPTConfiguration { public var apiKey: String { if let apiKey = overriding.apiKey { return apiKey } guard let name = model?.info.apiKeyName else { return configuration.apiKey } - return (try? Keychain.apiKey.get(name)) ?? configuration.apiKey + return (try? Keychain.apiKey.get(name)) ?? "" } public var shouldEndTextWindow: (String) -> Bool { From 811012747a90c569faa0a397a85a96f23b363d9e Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 25 Feb 2025 15:37:25 +0800 Subject: [PATCH 29/29] Fix header parsing --- Tool/Sources/OpenAIService/HeaderValueParser.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Tool/Sources/OpenAIService/HeaderValueParser.swift b/Tool/Sources/OpenAIService/HeaderValueParser.swift index 4ba74f33..0042ea75 100644 --- a/Tool/Sources/OpenAIService/HeaderValueParser.swift +++ b/Tool/Sources/OpenAIService/HeaderValueParser.swift @@ -15,7 +15,7 @@ public struct HeaderValueParser { public var apiKey: String public var gitHubCopilotToken: () async -> GitHubCopilotExtension.Token? public var shellEnvironmentVariable: (_ key: String) async -> String? - + public init( modelName: String, apiKey: String, @@ -66,7 +66,10 @@ public struct HeaderValueParser { } if let replacement { - parsedValue.replaceSubrange(range, with: replacement) + parsedValue.replaceSubrange( + range, + with: replacement.trimmingCharacters(in: .whitespacesAndNewlines) + ) } else { parsedValue.replaceSubrange(range, with: "none") }