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
44 changes: 24 additions & 20 deletions Paxo/AI/GeminiService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

private static let model = "gemini-2.5-flash"
#if DEBUG
private static let model = "gemini-3.6-flash"
#endif

/// 타임아웃을 설정한 공용 세션 (요청 30s / 리소스 90s)
private static let session: URLSession = {
Expand All @@ -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
}
Expand All @@ -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()
Expand Down
24 changes: 21 additions & 3 deletions Paxo/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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() {
Expand Down
3 changes: 2 additions & 1 deletion Paxo/UI/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
55 changes: 55 additions & 0 deletions PaxoTests/GeminiServiceTests.swift
Original file line number Diff line number Diff line change
@@ -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-3.6-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
}
2 changes: 1 addition & 1 deletion proxy-vercel/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
31 changes: 30 additions & 1 deletion proxy-vercel/api/generate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -59,6 +59,17 @@ 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,
providerMessage: diagnostic.providerMessage,
})
);
return fail(res, 502, "upstream error");
} catch (err) {
if (err && (err.name === "TimeoutError" || err.name === "AbortError")) {
Expand All @@ -70,6 +81,24 @@ 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, 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, providerMessage: "unknown" };
}
}

// Gemini 오류 형식과 동형 — GeminiService.serverMessage가 이 형태 하나만 파싱한다.
function fail(res, code, message) {
return res
Expand Down
Loading