Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions Sources/MrEditorCore/Core/AIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,12 @@ enum AIClient {
completion: @escaping (Result<String, ClientError>) -> 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)
Expand All @@ -71,7 +73,8 @@ enum AIClient {
static func send(_ prompt: AIPrompt,
completion: @escaping (Result<String, ClientError>) -> 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
}
Expand Down
34 changes: 30 additions & 4 deletions Sources/MrEditorCore/Core/AIConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")!
}
}

Expand All @@ -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"]
}
}

Expand All @@ -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"
}
}

Expand All @@ -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
}
}
10 changes: 7 additions & 3 deletions Sources/MrEditorCore/Core/AIRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion Sources/MrEditorCore/Core/AIStream.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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…";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" = "確認中…";
Expand Down
8 changes: 8 additions & 0 deletions Sources/MrEditorCore/UI/PreferencesWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
55 changes: 55 additions & 0 deletions Tests/MrEditorTests/AITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions scripts/make_app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,14 @@ fi)
<true/>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<!-- BYOK が Ollama 等ローカルの OpenAI 互換サーバへ届くための ATS 例外。
NSAllowsLocalNetworking は loopback (127.0.0.1 / localhost) と .local (mDNS) 相手に
限って平文 http を許す ── インターネット向けの接続は従来どおり https のみ。B18。 -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>
PLIST
Expand Down
Loading