From d325b881278f627b763f192c288fe979abd57b24 Mon Sep 17 00:00:00 2001 From: hyeseong Date: Mon, 21 Sep 2026 22:28:11 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=9D=EC=8B=9C=20?= =?UTF-8?q?=EC=97=85=EC=8A=A4=ED=8A=B8=EB=A6=BC=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=A7=84=EB=8B=A8=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DEBUG 직접 Gemini 호출 경로를 명시적으로 분리 - Gemini 업스트림 실패 상태를 비밀 없이 운영 로그에 기록 --- Paxo/AI/GeminiService.swift | 42 ++++++++++++----------- Paxo/AppState.swift | 24 +++++++++++-- Paxo/UI/SettingsView.swift | 3 +- PaxoTests/GeminiServiceTests.swift | 55 ++++++++++++++++++++++++++++++ proxy-vercel/api/generate.js | 26 ++++++++++++++ 5 files changed, 127 insertions(+), 23 deletions(-) create mode 100644 PaxoTests/GeminiServiceTests.swift diff --git a/Paxo/AI/GeminiService.swift b/Paxo/AI/GeminiService.swift index ac1e8d2..85c85fe 100644 --- a/Paxo/AI/GeminiService.swift +++ b/Paxo/AI/GeminiService.swift @@ -2,14 +2,17 @@ import Foundation /// AI 호출 서비스. /// -/// 릴리즈 빌드는 항상 프록시(paxo-proxy.vercel.app) 경유로 호출한다 — 앱에 Gemini 키를 두지 않는다. -/// 개발(DEBUG) 빌드에서만, 설정에서 프록시 URL을 비우고 API 키를 넣으면 Gemini를 직접 호출할 수 있다. +/// 릴리즈 빌드는 항상 프록시(api.paxo.co.kr) 경유로 호출한다 — 앱에 Gemini 키를 두지 않는다. +/// 개발(DEBUG) 빌드에서만 명시적으로 직접 호출을 선택하면 Gemini API를 호출할 수 있다. struct GeminiService { let apiKey: String let proxyURL: String let deviceID: String + let useDirectGemini: Bool + #if DEBUG private static let model = "gemini-2.5-flash" + #endif /// 타임아웃을 설정한 공용 세션 (요청 30s / 리소스 90s) private static let session: URLSession = { @@ -34,22 +37,26 @@ struct GeminiService { return user.isEmpty ? DefaultConfig.proxyURL : user } - private func makeRequest() throws -> URLRequest { - let proxy = effectiveProxyURL + func makeRequest() throws -> URLRequest { + #if DEBUG + if useDirectGemini { + return try makeDirectRequest() + } + #endif - if !proxy.isEmpty { - let base = proxy.hasSuffix("/") ? String(proxy.dropLast()) : proxy - guard let url = URL(string: base + "/generate") else { - throw GeminiError.badURL - } - var request = URLRequest(url: url) - request.setValue(deviceID, forHTTPHeaderField: "x-paxo-device") - request.setValue(DefaultConfig.appToken, forHTTPHeaderField: "x-paxo-token") - return request + let proxy = effectiveProxyURL + let base = proxy.hasSuffix("/") ? String(proxy.dropLast()) : proxy + guard let url = URL(string: base + "/generate") else { + throw GeminiError.badURL } + var request = URLRequest(url: url) + request.setValue(deviceID, forHTTPHeaderField: "x-paxo-device") + request.setValue(DefaultConfig.appToken, forHTTPHeaderField: "x-paxo-token") + return request + } - #if DEBUG - // 개발 빌드 전용: 프록시를 비우고 API 키를 넣었을 때만 Gemini 직접 호출 + #if DEBUG + private func makeDirectRequest() throws -> URLRequest { guard !apiKey.isEmpty else { throw GeminiError.missingKey } @@ -63,11 +70,8 @@ struct GeminiService { var request = URLRequest(url: url) request.setValue(apiKey, forHTTPHeaderField: "x-goog-api-key") return request - #else - // 릴리즈 빌드는 반드시 프록시 경유 — 여기 도달하면 설정 오류 - throw GeminiError.badURL - #endif } + #endif private func generate(prompt: String, imageData: Data) async throws -> String { var request = try makeRequest() diff --git a/Paxo/AppState.swift b/Paxo/AppState.swift index d98dfe8..d856963 100644 --- a/Paxo/AppState.swift +++ b/Paxo/AppState.swift @@ -88,14 +88,20 @@ final class AppState: ObservableObject { } } private var isSyncingLaunchAtLogin = false - /// 프록시 서버 URL. 설정되어 있으면 API 키 없이 프록시를 경유해 호출한다. (proxy/ 참고) + /// 프록시 서버 URL. 직접 호출이 꺼져 있으면 이 값을 우선 사용한다. @Published var proxyURL: String { didSet { UserDefaults.standard.set(proxyURL, forKey: "proxyURL") } } - /// 개발용 직접 호출 키. 프록시 URL이 비어 있을 때만 사용된다. + /// 개발용 직접 호출 키. 키체인에만 보관한다. @Published var apiKey: String { didSet { KeychainHelper.save(key: "gemini-api-key", value: apiKey) } } + #if DEBUG + /// 프록시 장애와 Gemini API 설정을 독립적으로 확인하기 위한 개발용 선택지. + @Published var useDirectGemini: Bool { + didSet { UserDefaults.standard.set(useDirectGemini, forKey: "useDirectGemini") } + } + #endif /// 프록시의 사용량 집계용 익명 기기 식별자 let deviceID: String @@ -145,6 +151,9 @@ final class AppState: ObservableObject { } proxyURL = UserDefaults.standard.string(forKey: "proxyURL") ?? "" apiKey = KeychainHelper.load(key: "gemini-api-key") ?? "" + #if DEBUG + useDirectGemini = UserDefaults.standard.bool(forKey: "useDirectGemini") + #endif launchAtLogin = (SMAppService.mainApp.status == .enabled) if let existing = UserDefaults.standard.string(forKey: "deviceID") { @@ -313,7 +322,16 @@ final class AppState: ObservableObject { } private func makeService() -> GeminiService { - GeminiService(apiKey: apiKey, proxyURL: proxyURL, deviceID: deviceID) + #if DEBUG + GeminiService( + apiKey: apiKey, + proxyURL: proxyURL, + deviceID: deviceID, + useDirectGemini: useDirectGemini + ) + #else + GeminiService(apiKey: apiKey, proxyURL: proxyURL, deviceID: deviceID, useDirectGemini: false) + #endif } private func upsertHistory() { diff --git a/Paxo/UI/SettingsView.swift b/Paxo/UI/SettingsView.swift index 313dd1b..48c91aa 100644 --- a/Paxo/UI/SettingsView.swift +++ b/Paxo/UI/SettingsView.swift @@ -80,8 +80,9 @@ struct SettingsView: View { .font(.caption) .foregroundStyle(.secondary) + Toggle("Gemini 직접 호출 (프록시 우회)", isOn: $appState.useDirectGemini) SecureField("Gemini API 키 (직접 호출용)", text: $appState.apiKey) - Text("프록시가 없을 때만 사용하는 개발용 키. 키체인에 저장됩니다.") + Text("켜면 프록시 URL을 무시하고 이 키로 Gemini를 직접 호출합니다. 키는 키체인에 저장됩니다.") .font(.caption) .foregroundStyle(.secondary) } diff --git a/PaxoTests/GeminiServiceTests.swift b/PaxoTests/GeminiServiceTests.swift new file mode 100644 index 0000000..b16d0a4 --- /dev/null +++ b/PaxoTests/GeminiServiceTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing + +@testable import Paxo + +struct GeminiServiceTests { + private let deviceID = "123E4567-E89B-12D3-A456-426614174000" + + @Test func 기본_요청은_프록시와_인증_헤더를_사용한다() throws { + let service = GeminiService( + apiKey: "development-key", + proxyURL: "https://proxy.example.com/", + deviceID: deviceID, + useDirectGemini: false + ) + + let request = try service.makeRequest() + + #expect(request.url?.absoluteString == "https://proxy.example.com/generate") + #expect(request.value(forHTTPHeaderField: "x-paxo-device") == deviceID) + #expect(request.value(forHTTPHeaderField: "x-paxo-token") != nil) + #expect(request.value(forHTTPHeaderField: "x-goog-api-key") == nil) + } + + #if DEBUG + @Test func 직접_호출은_프록시를_우회하고_Gemini_키를_사용한다() throws { + let service = GeminiService( + apiKey: "development-key", + proxyURL: "https://proxy.example.com", + deviceID: deviceID, + useDirectGemini: true + ) + + let request = try service.makeRequest() + + #expect(request.url?.host == "generativelanguage.googleapis.com") + #expect(request.url?.path.contains("gemini-2.5-flash:generateContent") == true) + #expect(request.value(forHTTPHeaderField: "x-goog-api-key") == "development-key") + #expect(request.value(forHTTPHeaderField: "x-paxo-token") == nil) + } + + @Test func 직접_호출은_키가_없으면_설정_오류를_반환한다() { + let service = GeminiService( + apiKey: "", + proxyURL: "https://proxy.example.com", + deviceID: deviceID, + useDirectGemini: true + ) + + #expect(throws: GeminiError.self) { + _ = try service.makeRequest() + } + } + #endif +} diff --git a/proxy-vercel/api/generate.js b/proxy-vercel/api/generate.js index 7092361..ab6e43f 100644 --- a/proxy-vercel/api/generate.js +++ b/proxy-vercel/api/generate.js @@ -59,6 +59,16 @@ export default async function handler(req, res) { .setHeader("content-type", "application/json") .send(text); } + // Gemini의 원문은 사용자에게 노출하지 않되, 운영 로그에서 키·모델·권한 문제를 구분한다. + const diagnostic = upstreamDiagnostic(text); + console.error( + JSON.stringify({ + event: "gemini_upstream_error", + status: upstream.status, + providerStatus: diagnostic.providerStatus, + providerCode: diagnostic.providerCode, + }) + ); return fail(res, 502, "upstream error"); } catch (err) { if (err && (err.name === "TimeoutError" || err.name === "AbortError")) { @@ -70,6 +80,22 @@ export default async function handler(req, res) { } } +function upstreamDiagnostic(text) { + try { + const parsed = JSON.parse(text); + const error = parsed && typeof parsed === "object" ? parsed.error : null; + if (!error || typeof error !== "object") { + return { providerStatus: "unknown", providerCode: null }; + } + return { + providerStatus: typeof error.status === "string" ? error.status : "unknown", + providerCode: Number.isInteger(error.code) ? error.code : null, + }; + } catch { + return { providerStatus: "unknown", providerCode: null }; + } +} + // Gemini 오류 형식과 동형 — GeminiService.serverMessage가 이 형태 하나만 파싱한다. function fail(res, code, message) { return res From 2636f2dd0c61692c3c4869bd569ea0705f7f5813 Mon Sep 17 00:00:00 2001 From: hyeseong Date: Tue, 22 Sep 2026 00:05:43 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20Gemini=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=9B=90=EC=9D=B8=20=EB=A1=9C=EA=B7=B8=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 업스트림 오류 메시지를 길이 제한해 서버 로그에만 기록 --- proxy-vercel/api/generate.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/proxy-vercel/api/generate.js b/proxy-vercel/api/generate.js index ab6e43f..cdd8c95 100644 --- a/proxy-vercel/api/generate.js +++ b/proxy-vercel/api/generate.js @@ -67,6 +67,7 @@ export default async function handler(req, res) { status: upstream.status, providerStatus: diagnostic.providerStatus, providerCode: diagnostic.providerCode, + providerMessage: diagnostic.providerMessage, }) ); return fail(res, 502, "upstream error"); @@ -85,14 +86,16 @@ function upstreamDiagnostic(text) { const parsed = JSON.parse(text); const error = parsed && typeof parsed === "object" ? parsed.error : null; if (!error || typeof error !== "object") { - return { providerStatus: "unknown", providerCode: null }; + return { providerStatus: "unknown", providerCode: null, providerMessage: "unknown" }; } return { providerStatus: typeof error.status === "string" ? error.status : "unknown", providerCode: Number.isInteger(error.code) ? error.code : null, + providerMessage: + typeof error.message === "string" ? error.message.slice(0, 300) : "unknown", }; } catch { - return { providerStatus: "unknown", providerCode: null }; + return { providerStatus: "unknown", providerCode: null, providerMessage: "unknown" }; } } From 82d9465c9ca1dd087fd3df94772c850245ff6c0f Mon Sep 17 00:00:00 2001 From: hyeseong Date: Tue, 22 Sep 2026 08:17:34 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EC=8B=A0=EA=B7=9C=20Gemini=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 신규 키에서 지원 중단된 gemini-2.5-flash를 gemini-3.6-flash로 교체 - 프록시와 DEBUG 직접 호출 모델을 일치 --- Paxo/AI/GeminiService.swift | 2 +- PaxoTests/GeminiServiceTests.swift | 2 +- proxy-vercel/AGENTS.md | 2 +- proxy-vercel/api/generate.js | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Paxo/AI/GeminiService.swift b/Paxo/AI/GeminiService.swift index 85c85fe..22784d5 100644 --- a/Paxo/AI/GeminiService.swift +++ b/Paxo/AI/GeminiService.swift @@ -11,7 +11,7 @@ struct GeminiService { let useDirectGemini: Bool #if DEBUG - private static let model = "gemini-2.5-flash" + private static let model = "gemini-3.6-flash" #endif /// 타임아웃을 설정한 공용 세션 (요청 30s / 리소스 90s) diff --git a/PaxoTests/GeminiServiceTests.swift b/PaxoTests/GeminiServiceTests.swift index b16d0a4..a83d3f1 100644 --- a/PaxoTests/GeminiServiceTests.swift +++ b/PaxoTests/GeminiServiceTests.swift @@ -34,7 +34,7 @@ struct GeminiServiceTests { let request = try service.makeRequest() #expect(request.url?.host == "generativelanguage.googleapis.com") - #expect(request.url?.path.contains("gemini-2.5-flash:generateContent") == true) + #expect(request.url?.path.contains("gemini-3.6-flash:generateContent") == true) #expect(request.value(forHTTPHeaderField: "x-goog-api-key") == "development-key") #expect(request.value(forHTTPHeaderField: "x-paxo-token") == nil) } diff --git a/proxy-vercel/AGENTS.md b/proxy-vercel/AGENTS.md index 34b56ed..be30ade 100644 --- a/proxy-vercel/AGENTS.md +++ b/proxy-vercel/AGENTS.md @@ -65,7 +65,7 @@ x-paxo-token: <공유 시크릿(Shared Secret)> | `GEMINI_API_KEY` | **필수** | — | 누락 시 서버 구동 불가 (HTTP 500 에러 발생) | | `APP_TOKEN` | 선택 | 없음 | 설정된 경우에만 클라이언트 토큰 검증 로직이 활성화됨 | | `APP_TOKEN_PREV` | 선택 | 없음 | 구버전 하위 호환 및 토큰 로테이션 목적의 예비 슬롯 | -| `MODEL` | 선택 | `gemini-2.5-flash` | 호출할 업스트림 LLM 모델 지정 | +| `MODEL` | 선택 | `gemini-3.6-flash` | 호출할 업스트림 LLM 모델 지정 | ## 5. 코드 스타일 (Code Convention) diff --git a/proxy-vercel/api/generate.js b/proxy-vercel/api/generate.js index cdd8c95..98d3b5d 100644 --- a/proxy-vercel/api/generate.js +++ b/proxy-vercel/api/generate.js @@ -32,7 +32,7 @@ export default async function handler(req, res) { const body = buildUpstreamBody(req.body); - const model = process.env.MODEL || "gemini-2.5-flash"; + const model = process.env.MODEL || "gemini-3.6-flash"; const upstream = await fetch(`${GEMINI_BASE}/${model}:generateContent`, { method: "POST", headers: {