From d139511e31dfc0ff9d0ef8d4c5b5c6a108e4868f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E7=95=91=E3=81=B2=E3=81=A8=E3=81=97?= Date: Thu, 24 Sep 2026 20:35:14 +0900 Subject: [PATCH] =?UTF-8?q?BYOK=20=E3=81=AB=20Ollama=EF=BC=88=E3=83=AD?= =?UTF-8?q?=E3=83=BC=E3=82=AB=E3=83=AB=EF=BC=89=E3=82=92=E8=B6=B3=E3=81=99?= =?UTF-8?q?=20(B18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AIProvider.ollama を追加(キー不要、既定 http://localhost:11434)。ATS の NSAllowsLocalNetworking を Info.plist に足して loopback への平文 http を許可し、 AIConfig.baseURL も loopback 相手なら http の上書きを通すようにした。 AIClient のキー必須ガードは provider.requiresAPIKey で分岐。リクエスト/レスポンス/ SSE は OpenAI 互換の /v1/chat/completions をそのまま流用する。 実機の Ollama(llama3.2:1b)で送信・ストリーミング両方を確認済み。 Co-Authored-By: Claude Sonnet 5 --- Sources/MrEditorCore/Core/AIClient.swift | 7 ++- Sources/MrEditorCore/Core/AIConfig.swift | 34 ++++++++++-- Sources/MrEditorCore/Core/AIRequest.swift | 10 +++- Sources/MrEditorCore/Core/AIStream.swift | 2 +- .../Resources/en.lproj/Localizable.strings | 1 + .../Resources/ja.lproj/Localizable.strings | 1 + .../UI/PreferencesWindowController.swift | 8 +++ Tests/MrEditorTests/AITests.swift | 55 +++++++++++++++++++ scripts/make_app.sh | 8 +++ 9 files changed, 116 insertions(+), 10 deletions(-) diff --git a/Sources/MrEditorCore/Core/AIClient.swift b/Sources/MrEditorCore/Core/AIClient.swift index f8b356c..8287e49 100644 --- a/Sources/MrEditorCore/Core/AIClient.swift +++ b/Sources/MrEditorCore/Core/AIClient.swift @@ -48,10 +48,12 @@ enum AIClient { completion: @escaping (Result) -> Void) -> AIStreamHandle { let handle = AIStreamHandle(onDelta: onDelta, completion: completion) let config = AppSettings.aiConfig - guard let key = Keychain.get(account: config.provider.keychainAccount), !key.isEmpty else { + let storedKey = Keychain.get(account: config.provider.keychainAccount) ?? "" + guard !config.provider.requiresAPIKey || !storedKey.isEmpty else { handle.failLater(.notConfigured) return handle } + let key = storedKey let request: URLRequest do { var r = try AIRequestBuilder.makeRequest(prompt, config: config, apiKey: key, stream: true) @@ -71,7 +73,8 @@ enum AIClient { static func send(_ prompt: AIPrompt, completion: @escaping (Result) -> Void) { let config = AppSettings.aiConfig - guard let key = Keychain.get(account: config.provider.keychainAccount), !key.isEmpty else { + let key = Keychain.get(account: config.provider.keychainAccount) ?? "" + guard !config.provider.requiresAPIKey || !key.isEmpty else { DispatchQueue.main.async { completion(.failure(.notConfigured)) } return } diff --git a/Sources/MrEditorCore/Core/AIConfig.swift b/Sources/MrEditorCore/Core/AIConfig.swift index f4a8604..c90f9fc 100644 --- a/Sources/MrEditorCore/Core/AIConfig.swift +++ b/Sources/MrEditorCore/Core/AIConfig.swift @@ -6,21 +6,31 @@ enum AIProvider: String, CaseIterable, Codable { case anthropic case openAI case gemini + /// ローカルの Ollama(OpenAI 互換 API)。キーは要らない。B18。 + case ollama var displayName: String { switch self { case .anthropic: return "Anthropic (Claude)" case .openAI: return "OpenAI" case .gemini: return "Google (Gemini)" + case .ollama: return "Ollama (local)" } } + /// キーが要るか。**Ollama だけ要らない**(ローカルなので鍵で守る相手がいない)。 + /// [[AIClient]] のキー必須ガード、[[AIRequestBuilder.makeRequest]] の空キー拒否はここを見る。 + var requiresAPIKey: Bool { self != .ollama } + /// 既定のエンドポイント。config の baseURLOverride で上書きでき、OpenAI 互換サーバへ向けられる。 + /// Ollama だけ既定が http ── ローカル向けの ATS 例外(NSAllowsLocalNetworking)を + /// Info.plist に足してあるので、127.0.0.1 / localhost / .local 相手はこれで届く。 var defaultBaseURL: URL { switch self { case .anthropic: return URL(string: "https://api.anthropic.com")! case .openAI: return URL(string: "https://api.openai.com")! case .gemini: return URL(string: "https://generativelanguage.googleapis.com")! + case .ollama: return URL(string: "http://localhost:11434")! } } @@ -41,6 +51,10 @@ enum AIProvider: String, CaseIterable, Codable { // 次々引退させるので、一覧に固定版を焼き込むと腐る。使いたい人は打ち込めばよく、 // 接続テストに通れば次から一覧に出る。 return ["gemini-flash-latest", "gemini-pro-latest", "gemini-flash-lite-latest"] + case .ollama: + // インストールしたモデルは人によって違う。`ollama list` で入っているものが正で、 + // ここは「よくある名前」の手がかりでしかない。 + return ["llama3.2", "qwen2.5", "mistral", "gemma2"] } } @@ -64,6 +78,7 @@ enum AIProvider: String, CaseIterable, Codable { case .anthropic: return "claude-opus-4-8" case .openAI: return "gpt-4o" case .gemini: return "gemini-flash-latest" + case .ollama: return "llama3.2" } } @@ -76,17 +91,28 @@ enum AIProvider: String, CaseIterable, Codable { struct AIConfig: Equatable, Codable { var provider: AIProvider var model: String - /// OpenAI 互換サーバ等へ向けるためのベース URL 上書き(空=既定)。**https 限定** - /// (配布 .app は ATS 例外なし=平文 http は実機で -1022。[[ats-url-fetch-https-only]])。 + /// OpenAI 互換サーバ等へ向けるためのベース URL 上書き(空=既定)。**https 限定、 + /// ただし loopback/.local 相手は http も通す**(配布 .app は ATS に + /// `NSAllowsLocalNetworking` を足してあるので、そこだけ平文が届く。 + /// それ以外の http は実機で -1022。[[ats-url-fetch-https-only]])。 var baseURLOverride: String static let `default` = AIConfig(provider: .anthropic, model: AIProvider.anthropic.defaultModel, baseURLOverride: "") - /// 実効ベース URL。上書きが有効な https URL ならそれ、無ければプロバイダ既定。 + /// ループバック/mDNS など、ATS の `NSAllowsLocalNetworking` が平文でも通す相手か。 + private static func isLocalHost(_ host: String) -> Bool { + host == "localhost" || host == "127.0.0.1" || host == "::1" || host.hasSuffix(".local") + } + + /// 実効ベース URL。上書きが有効な https URL、または loopback 相手の http ならそれ、 + /// 無ければプロバイダ既定(Ollama の既定自体が http なのでここは通らない)。 var baseURL: URL { - if let u = URL(string: baseURLOverride), u.scheme == "https", u.host != nil { return u } + if let u = URL(string: baseURLOverride), let host = u.host, + u.scheme == "https" || (u.scheme == "http" && Self.isLocalHost(host)) { + return u + } return provider.defaultBaseURL } } diff --git a/Sources/MrEditorCore/Core/AIRequest.swift b/Sources/MrEditorCore/Core/AIRequest.swift index 5b83c96..02a3588 100644 --- a/Sources/MrEditorCore/Core/AIRequest.swift +++ b/Sources/MrEditorCore/Core/AIRequest.swift @@ -23,10 +23,14 @@ enum AIRequestBuilder { /// `stream` が真なら SSE(`text/event-stream`)で受け取る形にする=差分表示用。 static func makeRequest(_ prompt: AIPrompt, config: AIConfig, apiKey: String, stream: Bool = false) throws -> URLRequest { - guard !apiKey.isEmpty else { throw AIError(message: "missing API key") } + guard !apiKey.isEmpty || !config.provider.requiresAPIKey else { + throw AIError(message: "missing API key") + } switch config.provider { case .anthropic: return anthropic(prompt, config: config, apiKey: apiKey, stream: stream) - case .openAI: return openAI(prompt, config: config, apiKey: apiKey, stream: stream) + // Ollama は OpenAI 互換の `/v1/chat/completions` をそのまま話す。キーが空でも + // `Authorization: Bearer ` が付くだけで、Ollama 側は見ていないので害はない。 + case .openAI, .ollama: return openAI(prompt, config: config, apiKey: apiKey, stream: stream) case .gemini: return try gemini(prompt, config: config, apiKey: apiKey, stream: stream) } } @@ -139,7 +143,7 @@ enum AIRequestBuilder { }.joined() guard !text.isEmpty else { throw AIError.emptyResponse } return text - case .openAI: + case .openAI, .ollama: guard let choices = obj["choices"] as? [[String: Any]], let message = choices.first?["message"] as? [String: Any], let text = message["content"] as? String, !text.isEmpty else { diff --git a/Sources/MrEditorCore/Core/AIStream.swift b/Sources/MrEditorCore/Core/AIStream.swift index 47a2739..c137086 100644 --- a/Sources/MrEditorCore/Core/AIStream.swift +++ b/Sources/MrEditorCore/Core/AIStream.swift @@ -65,7 +65,7 @@ struct AIStreamDecoder { default: return [] // ping / message_start / usage 等 } - case .openAI: + case .openAI, .ollama: guard let choices = obj["choices"] as? [[String: Any]] else { return [] } let text = choices.compactMap { ($0["delta"] as? [String: Any])?["content"] as? String }.joined() return text.isEmpty ? [] : [.delta(text)] diff --git a/Sources/MrEditorCore/Resources/en.lproj/Localizable.strings b/Sources/MrEditorCore/Resources/en.lproj/Localizable.strings index 9696021..0e3ad9c 100644 --- a/Sources/MrEditorCore/Resources/en.lproj/Localizable.strings +++ b/Sources/MrEditorCore/Resources/en.lproj/Localizable.strings @@ -331,6 +331,7 @@ "prefs.ai.apiKey" = "API key"; "prefs.ai.baseURL" = "Base URL (optional)"; "prefs.ai.baseURLPlaceholder" = "OpenAI-compatible server (https only)"; +"prefs.ai.baseURLNotNeeded" = "Not needed (uses the local default URL)"; "prefs.ai.note" = "Your key is stored in the Keychain. You bring your own key (BYOK); it is used only for single-shot analysis of the current selection.\nA model that is not in the list can be typed in. Once it passes the connection test, it appears in the list from then on."; "prefs.ai.test" = "Test connection"; "prefs.ai.testing" = "Testing…"; diff --git a/Sources/MrEditorCore/Resources/ja.lproj/Localizable.strings b/Sources/MrEditorCore/Resources/ja.lproj/Localizable.strings index f4656e9..5d45d0d 100644 --- a/Sources/MrEditorCore/Resources/ja.lproj/Localizable.strings +++ b/Sources/MrEditorCore/Resources/ja.lproj/Localizable.strings @@ -331,6 +331,7 @@ "prefs.ai.apiKey" = "API キー"; "prefs.ai.baseURL" = "ベース URL(任意)"; "prefs.ai.baseURLPlaceholder" = "OpenAI 互換サーバ(https のみ)"; +"prefs.ai.baseURLNotNeeded" = "不要(ローカルの既定 URL を使用)"; "prefs.ai.note" = "キーは Keychain に保存されます。原価はあなたの鍵持ちです(BYOK)。選択範囲の単発解析にのみ使います。\n一覧に無いモデルは直接入力できます。接続テストに通ると、次から一覧に出ます。"; "prefs.ai.test" = "接続テスト"; "prefs.ai.testing" = "確認中…"; diff --git a/Sources/MrEditorCore/UI/PreferencesWindowController.swift b/Sources/MrEditorCore/UI/PreferencesWindowController.swift index ae2527d..fabc4e3 100644 --- a/Sources/MrEditorCore/UI/PreferencesWindowController.swift +++ b/Sources/MrEditorCore/UI/PreferencesWindowController.swift @@ -608,6 +608,14 @@ private final class AIPaneViewController: NSViewController, NSTextFieldDelegate, baseURLField.stringValue = config.baseURLOverride keyField.stringValue = Keychain.get(account: config.provider.keychainAccount) ?? "" testResultLabel.stringValue = "" + + // Ollama はローカルの既定 URL に鍵無しで届くので、両方とも打つ意味が無い。 + // 空にはしない(打ってあったものを消したくない)── 触れなくするだけ。 + let needsRemoteConfig = config.provider.requiresAPIKey + keyField.isEnabled = needsRemoteConfig + baseURLField.isEnabled = needsRemoteConfig + baseURLField.placeholderString = needsRemoteConfig + ? L("prefs.ai.baseURLPlaceholder") : L("prefs.ai.baseURLNotNeeded") } private var currentProvider: AIProvider { diff --git a/Tests/MrEditorTests/AITests.swift b/Tests/MrEditorTests/AITests.swift index aa3fd7b..80ff08e 100644 --- a/Tests/MrEditorTests/AITests.swift +++ b/Tests/MrEditorTests/AITests.swift @@ -135,6 +135,61 @@ final class AITests: XCTestCase { AIPrompt(system: nil, user: "x", maxTokens: 10), config: .default, apiKey: "")) } + // MARK: - Ollama(ローカル・キー不要。B18) + + func testOllamaDoesNotRequireAPIKey() { + XCTAssertFalse(AIProvider.ollama.requiresAPIKey) + for p: AIProvider in [.anthropic, .openAI, .gemini] { XCTAssertTrue(p.requiresAPIKey) } + } + + /// 空キーでも投げない(他プロバイダは `testMissingKeyThrows` の通り投げる)。 + func testMakeOllamaRequestWithoutKey() throws { + let cfg = AIConfig(provider: .ollama, model: "llama3.2", baseURLOverride: "") + let req = try AIRequestBuilder.makeRequest( + AIPrompt(system: "SYS", user: "hi", maxTokens: 256), config: cfg, apiKey: "") + XCTAssertEqual(req.url?.absoluteString, "http://localhost:11434/v1/chat/completions") + // 空でも Bearer ヘッダ自体は付く(Ollama 側は見ない)。 + XCTAssertEqual(req.value(forHTTPHeaderField: "Authorization"), "Bearer ") + let msgs = body(req)["messages"] as? [[String: String]] + XCTAssertEqual(msgs?[1]["content"], "hi") + } + + /// 既定が http のまま(loopback は ATS の NSAllowsLocalNetworking で届く)。 + func testOllamaDefaultBaseURLIsPlainHTTP() { + let cfg = AIConfig(provider: .ollama, model: "llama3.2", baseURLOverride: "") + XCTAssertEqual(cfg.baseURL, URL(string: "http://localhost:11434")) + } + + /// 上書きも loopback 相手なら http を通す(別ポートで動かしている場合等)。 + /// loopback 以外の http は従来どおり拒否して既定へ落ちる。 + func testBaseURLOverrideAllowsLoopbackHTTP() { + var cfg = AIConfig(provider: .ollama, model: "m", baseURLOverride: "http://127.0.0.1:1234") + XCTAssertEqual(cfg.baseURL, URL(string: "http://127.0.0.1:1234")) + cfg.baseURLOverride = "http://localhost:9999" + XCTAssertEqual(cfg.baseURL, URL(string: "http://localhost:9999")) + cfg.baseURLOverride = "http://not-local.example.com" + XCTAssertEqual(cfg.baseURL, AIProvider.ollama.defaultBaseURL) + } + + func testParseOllamaResponse() throws { + let json = #"{"choices":[{"message":{"role":"assistant","content":"try -Xmx"}}]}"# + let text = try AIRequestBuilder.parseResponse(json.data(using: .utf8)!, provider: .ollama) + XCTAssertEqual(text, "try -Xmx") + } + + func testDecodeOllamaStream() { + var d = AIStreamDecoder(provider: .ollama) + let events = feed(&d, """ + data: {"choices":[{"delta":{"content":"try "}}]} + + data: {"choices":[{"delta":{"content":"-Xmx"}}]} + + data: [DONE] + + """) + XCTAssertEqual(events, [.delta("try "), .delta("-Xmx"), .done]) + } + // MARK: - AIRequestBuilder(解析) func testParseAnthropicResponse() throws { diff --git a/scripts/make_app.sh b/scripts/make_app.sh index 06603d7..401360e 100755 --- a/scripts/make_app.sh +++ b/scripts/make_app.sh @@ -171,6 +171,14 @@ fi) NSPrincipalClass NSApplication + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + PLIST