diff --git a/AGENTS.md b/AGENTS.md index ebbf146..5be9fc3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -347,8 +347,8 @@ https://github.com/settings/apps/dalestudy ### 4. Worker Secrets 설정 ```bash -# OpenAI API Key (AI 코드 리뷰용, 필수) -wrangler secret put OPENAI_API_KEY +# AI Gateway 인증 토큰 (AI 코드 리뷰용, 필수) +wrangler secret put AI_GATEWAY_TOKEN # Webhook Secret (선택사항) wrangler secret put WEBHOOK_SECRET diff --git a/handlers/complexity-analysis.js b/handlers/complexity-analysis.js index e30e3f8..d574d47 100644 --- a/handlers/complexity-analysis.js +++ b/handlers/complexity-analysis.js @@ -10,6 +10,8 @@ * - LLM : actualTime / actualSpace / feedback / suggestion / headerLine 만 책임. */ +import { aiGatewayHeaders, OPENAI_CHAT_COMPLETIONS_URL } from "../utils/constants.js"; + // ── 상수 ────────────────────────────────────────── const FILE_DELIMITER = "====="; @@ -240,7 +242,7 @@ export function composeSolution(modelSol, originalContent) { }; } -export async function callComplexityAnalysis(fileEntries, apiKey) { +export async function callComplexityAnalysis(fileEntries, gatewayToken) { const userPrompt = fileEntries .map( (f) => @@ -248,12 +250,9 @@ export async function callComplexityAnalysis(fileEntries, apiKey) { ) .join("\n\n"); - const response = await fetch("https://api.openai.com/v1/chat/completions", { + const response = await fetch(OPENAI_CHAT_COMPLETIONS_URL, { method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, + headers: aiGatewayHeaders(gatewayToken), body: JSON.stringify({ model: "gpt-5-nano", messages: [ diff --git a/handlers/complexity-analysis.test.js b/handlers/complexity-analysis.test.js index 89088d7..cd5cf9f 100644 --- a/handlers/complexity-analysis.test.js +++ b/handlers/complexity-analysis.test.js @@ -557,6 +557,26 @@ function solution() { return 0; }`; expect(captured).not.toContain("TC: O(n)"); expect(captured).not.toContain("SC: O(1)"); }); + + it("AI Gateway 인증 헤더와 재시도 헤더를 함께 보낸다", async () => { + let captured = null; + globalThis.fetch = vi.fn().mockImplementation(async (url, opts) => { + captured = opts.headers; + return await makeOpenAIResponse([makeSingleSolutionAnalysis("two-sum")]); + }); + + await callComplexityAnalysis( + [{ problemName: "two-sum", content: PLAIN_SOURCE }], + "fake-key" + ); + + expect(captured).toMatchObject({ + "cf-aig-authorization": "Bearer fake-key", + "cf-aig-max-attempts": "3", + "cf-aig-retry-delay": "1000", + "cf-aig-backoff": "exponential", + }); + }); }); // ── renderComplexitySection ─────────────────────── diff --git a/handlers/internal-dispatch.js b/handlers/internal-dispatch.js index f060b78..45e33fe 100644 --- a/handlers/internal-dispatch.js +++ b/handlers/internal-dispatch.js @@ -72,7 +72,7 @@ async function handleTagPatterns(payload, appToken, env) { headSha, prData, appToken, - env.OPENAI_API_KEY + env.AI_GATEWAY_TOKEN ); return corsResponse({ handler: "tag-patterns", result }); } @@ -85,7 +85,7 @@ async function handleLearningStatus(payload, appToken, env) { prNumber, username, appToken, - env.OPENAI_API_KEY + env.AI_GATEWAY_TOKEN ); return corsResponse({ handler: "learning-status", result }); } diff --git a/handlers/internal-dispatch.test.js b/handlers/internal-dispatch.test.js index 6522889..0a335da 100644 --- a/handlers/internal-dispatch.test.js +++ b/handlers/internal-dispatch.test.js @@ -88,7 +88,7 @@ describe("handleInternalDispatch — 인증", () => { }); describe("handleInternalDispatch — 라우팅", () => { - const env = { INTERNAL_SECRET: VALID_SECRET, OPENAI_API_KEY: "fake-openai" }; + const env = { INTERNAL_SECRET: VALID_SECRET, AI_GATEWAY_TOKEN: "fake-gateway-token" }; beforeEach(() => { vi.clearAllMocks(); @@ -123,7 +123,7 @@ describe("handleInternalDispatch — 라우팅", () => { "abc123", prData, "fake-token", - "fake-openai" + "fake-gateway-token" ); expect(postLearningStatus).not.toHaveBeenCalled(); }); @@ -154,7 +154,7 @@ describe("handleInternalDispatch — 라우팅", () => { 42, "testuser", "fake-token", - "fake-openai" + "fake-gateway-token" ); expect(tagPatterns).not.toHaveBeenCalled(); }); @@ -200,7 +200,7 @@ describe("handleInternalDispatch — 라우팅", () => { }); describe("handleInternalDispatch — 에러 처리", () => { - const env = { INTERNAL_SECRET: VALID_SECRET, OPENAI_API_KEY: "fake-openai" }; + const env = { INTERNAL_SECRET: VALID_SECRET, AI_GATEWAY_TOKEN: "fake-gateway-token" }; beforeEach(() => { vi.clearAllMocks(); diff --git a/handlers/webhooks.js b/handlers/webhooks.js index 39058e9..153cb2a 100644 --- a/handlers/webhooks.js +++ b/handlers/webhooks.js @@ -260,7 +260,7 @@ async function handlePullRequestEvent(payload, env, ctx) { } // AI 핸들러들을 별도 Worker 호출로 디스패치 (각각 독립적인 subrequest 예산) - if (env.OPENAI_API_KEY && env.INTERNAL_SECRET && env.WORKER_URL) { + if (env.AI_GATEWAY_TOKEN && env.INTERNAL_SECRET && env.WORKER_URL) { const baseUrl = env.WORKER_URL; const dispatchHeaders = { @@ -300,7 +300,7 @@ async function handlePullRequestEvent(payload, env, ctx) { ); console.log(`[handlePullRequestEvent] Dispatched 2 AI handlers for PR #${prNumber}`); - } else if (env.OPENAI_API_KEY) { + } else if (env.AI_GATEWAY_TOKEN) { // INTERNAL_SECRET/WORKER_URL 미설정 시 기존 방식으로 폴백 (동일 invocation에서 순차 실행) console.warn("[handlePullRequestEvent] INTERNAL_SECRET or WORKER_URL not set, running handlers in-process"); @@ -312,14 +312,14 @@ async function handlePullRequestEvent(payload, env, ctx) { pr.head.sha, pr, appToken, - env.OPENAI_API_KEY + env.AI_GATEWAY_TOKEN ); } catch (error) { console.error(`[handlePullRequestEvent] tagPatterns failed: ${error.message}`); } try { - await postLearningStatus(repoOwner, repoName, prNumber, pr.user.login, appToken, env.OPENAI_API_KEY); + await postLearningStatus(repoOwner, repoName, prNumber, pr.user.login, appToken, env.AI_GATEWAY_TOKEN); } catch (error) { console.error(`[handlePullRequestEvent] learningStatus failed: ${error.message}`); } @@ -483,9 +483,9 @@ async function handleIssueCommentEvent(payload, env) { // AI 코드 리뷰 요청 처리 console.log(`AI review requested for PR #${prNumber}${mention.userRequest ? ` - Request: ${mention.userRequest}` : ""}`); - // OPENAI_API_KEY 확인 - if (!env.OPENAI_API_KEY) { - console.log("OPENAI_API_KEY not configured"); + // AI_GATEWAY_TOKEN 확인 + if (!env.AI_GATEWAY_TOKEN) { + console.log("AI_GATEWAY_TOKEN not configured"); return corsResponse({ message: "AI review not configured" }); } @@ -508,7 +508,7 @@ async function handleIssueCommentEvent(payload, env) { issue.title, issue.body, appToken, - env.OPENAI_API_KEY, + env.AI_GATEWAY_TOKEN, mention.userRequest ); @@ -553,9 +553,9 @@ async function handlePullRequestReviewCommentEvent(payload, env) { console.log(`AI review requested for PR #${prNumber} (review comment)${mention.userRequest ? ` - Request: ${mention.userRequest}` : ""}`); - // OPENAI_API_KEY 확인 - if (!env.OPENAI_API_KEY) { - console.log("OPENAI_API_KEY not configured"); + // AI_GATEWAY_TOKEN 확인 + if (!env.AI_GATEWAY_TOKEN) { + console.log("AI_GATEWAY_TOKEN not configured"); return corsResponse({ message: "AI review not configured" }); } @@ -581,7 +581,7 @@ async function handlePullRequestReviewCommentEvent(payload, env) { pullRequest.title, pullRequest.body, appToken, - env.OPENAI_API_KEY, + env.AI_GATEWAY_TOKEN, mention.userRequest, comment.id // 스레드 답변으로 작성 ); diff --git a/handlers/webhooks.test.js b/handlers/webhooks.test.js index 5598bf3..f862b14 100644 --- a/handlers/webhooks.test.js +++ b/handlers/webhooks.test.js @@ -264,10 +264,10 @@ describe("handlePullRequestEvent — AI 핸들러 디스패치", () => { }); }); - it("OPENAI_API_KEY, INTERNAL_SECRET, WORKER_URL 이 모두 설정되면 ctx.waitUntil 로 self-fetch 2 회를 디스패치한다", async () => { + it("AI_GATEWAY_TOKEN, INTERNAL_SECRET, WORKER_URL 이 모두 설정되면 ctx.waitUntil 로 self-fetch 2 회를 디스패치한다", async () => { const ctx = makeCtx(); const env = { - OPENAI_API_KEY: "fake-openai", + AI_GATEWAY_TOKEN: "fake-gateway-token", INTERNAL_SECRET: "fake-secret", WORKER_URL: "https://worker.test", }; @@ -299,7 +299,7 @@ describe("handlePullRequestEvent — AI 핸들러 디스패치", () => { it("INTERNAL_SECRET 이 없으면 in-process 핸들러 호출로 폴백한다", async () => { const ctx = makeCtx(); const env = { - OPENAI_API_KEY: "fake-openai", + AI_GATEWAY_TOKEN: "fake-gateway-token", WORKER_URL: "https://worker.test", }; @@ -323,7 +323,7 @@ describe("handlePullRequestEvent — AI 핸들러 디스패치", () => { it("WORKER_URL 이 없으면 in-process 핸들러 호출로 폴백한다", async () => { const ctx = makeCtx(); const env = { - OPENAI_API_KEY: "fake-openai", + AI_GATEWAY_TOKEN: "fake-gateway-token", INTERNAL_SECRET: "fake-secret", }; @@ -339,7 +339,7 @@ describe("handlePullRequestEvent — AI 핸들러 디스패치", () => { expect(postLearningStatus).toHaveBeenCalledTimes(1); }); - it("OPENAI_API_KEY 가 없으면 디스패치도 핸들러 호출도 하지 않는다", async () => { + it("AI_GATEWAY_TOKEN 가 없으면 디스패치도 핸들러 호출도 하지 않는다", async () => { const ctx = makeCtx(); const env = { INTERNAL_SECRET: "fake-secret", diff --git a/tests/subrequest-budget.test.js b/tests/subrequest-budget.test.js index f923bbf..362d3c8 100644 --- a/tests/subrequest-budget.test.js +++ b/tests/subrequest-budget.test.js @@ -59,7 +59,7 @@ describe("subrequest 예산 — 핸들러별 invocation", () => { return okText("function solution() { return 0; }"); } - if (urlStr.includes("openai.com/v1/chat/completions")) { + if (urlStr.includes("/openai/chat/completions")) { const body = JSON.parse(opts.body); const isComplexity = body.messages[0].content.includes( "시간/공간 복잡도를 분석" @@ -225,7 +225,7 @@ describe("subrequest 예산 — 핸들러별 invocation", () => { return okText("function solution() { return 0; }"); } - if (urlStr.includes("openai.com/v1/chat/completions")) { + if (urlStr.includes("/openai/chat/completions")) { return okJson({ choices: [ { diff --git a/tests/tag-patterns.test.js b/tests/tag-patterns.test.js index 759883d..1c8bd37 100644 --- a/tests/tag-patterns.test.js +++ b/tests/tag-patterns.test.js @@ -99,7 +99,7 @@ function makeFetchMock({ return okText(rawContent); } - if (urlStr.includes("openai.com/v1/chat/completions")) { + if (urlStr.includes("/openai/chat/completions")) { const body = JSON.parse(opts.body); const isComplexity = body.messages[0].content.includes( "시간/공간 복잡도를 분석" @@ -427,7 +427,7 @@ describe("tagPatterns — 레거시 단독 복잡도 issue comment 마이그레 if (urlStr.startsWith("https://raw.example.com/")) { return okText(PLAIN_SOURCE); } - if (urlStr.includes("openai.com")) { + if (urlStr.includes("/openai/chat/completions")) { const body = JSON.parse(opts.body); const isComplexity = body.messages[0].content.includes( "시간/공간 복잡도를 분석" @@ -495,7 +495,7 @@ describe("tagPatterns — 레거시 단독 복잡도 issue comment 마이그레 if (urlStr.startsWith("https://raw.example.com/")) { return okText(PLAIN_SOURCE); } - if (urlStr.includes("openai.com")) { + if (urlStr.includes("/openai/chat/completions")) { const body = JSON.parse(opts.body); const isComplexity = body.messages[0].content.includes( "시간/공간 복잡도를 분석" @@ -557,7 +557,7 @@ describe("tagPatterns — 레거시 단독 복잡도 issue comment 마이그레 if (urlStr.startsWith("https://raw.example.com/")) { return okText(PLAIN_SOURCE); } - if (urlStr.includes("openai.com")) { + if (urlStr.includes("/openai/chat/completions")) { const body = JSON.parse(opts.body); const isComplexity = body.messages[0].content.includes( "시간/공간 복잡도를 분석" diff --git a/utils/constants.js b/utils/constants.js index 1e87a01..aca9752 100644 --- a/utils/constants.js +++ b/utils/constants.js @@ -15,3 +15,32 @@ export const ALLOWED_REPO = "leetcode-study"; // 라벨 export const MAINTENANCE_LABEL = "maintenance"; + +// AI Gateway 를 거치는 OpenAI 엔드포인트. +// OpenAI 키는 게이트웨이에 저장돼 있어 요청에 싣지 않는다. 키를 실으면 +// 게이트웨이가 저장된 키를 끼워 넣지 않고 그대로 전달한다. +const AI_GATEWAY_ACCOUNT_ID = "86aa227176a624680f7d34e691472576"; +const AI_GATEWAY_ID = "dalestudy"; +export const OPENAI_CHAT_COMPLETIONS_URL = `https://gateway.ai.cloudflare.com/v1/${AI_GATEWAY_ACCOUNT_ID}/${AI_GATEWAY_ID}/openai/chat/completions`; + +// 게이트웨이에 재시도를 맡긴다. Worker 안에서 재시도하면 CPU 예산을 깎지만, +// 엣지에서 재시도하면 응답을 기다리는 시간일 뿐이다. +const AI_GATEWAY_RETRY_HEADERS = { + "cf-aig-max-attempts": "3", + "cf-aig-retry-delay": "1000", + "cf-aig-backoff": "exponential", +}; + +/** + * AI Gateway 경유 OpenAI 호출에 붙일 헤더. + * + * @param {string} gatewayToken - AI Gateway 인증 토큰 + * @returns {Record} + */ +export function aiGatewayHeaders(gatewayToken) { + return { + "cf-aig-authorization": `Bearer ${gatewayToken}`, + "Content-Type": "application/json", + ...AI_GATEWAY_RETRY_HEADERS, + }; +} diff --git a/utils/openai.js b/utils/openai.js index 99cda69..796d2ad 100644 --- a/utils/openai.js +++ b/utils/openai.js @@ -1,6 +1,7 @@ /** - * OpenAI API 통합 (GPT-4.1-nano) + * OpenAI API 통합 (AI Gateway 경유) */ +import { aiGatewayHeaders, OPENAI_CHAT_COMPLETIONS_URL } from "./constants.js"; /** * PR diff를 분석하여 AI 코드 리뷰 생성 @@ -8,7 +9,7 @@ * @param {string} prDiff - PR의 diff 내용 * @param {string} prTitle - PR 제목 * @param {string} prBody - PR 본문 - * @param {string} apiKey - OpenAI API 키 + * @param {string} gatewayToken - AI Gateway 인증 토큰 * @param {string} userRequest - 사용자의 구체적인 요청 (선택사항) * @returns {Promise} AI가 생성한 리뷰 댓글 (마크다운) */ @@ -16,7 +17,7 @@ export async function generateCodeReview( prDiff, prTitle, prBody, - apiKey, + gatewayToken, userRequest = null ) { // userRequest가 있으면 Q&A 모드, 없으면 전체 리뷰 모드 @@ -62,12 +63,9 @@ ${prDiff} userPrompt += `\n이 풀 리퀘스트를 리뷰해주세요.`; } - const response = await fetch("https://api.openai.com/v1/chat/completions", { + const response = await fetch(OPENAI_CHAT_COMPLETIONS_URL, { method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, + headers: aiGatewayHeaders(gatewayToken), body: JSON.stringify({ model: "gpt-5-nano", messages: [ @@ -93,10 +91,10 @@ ${prDiff} * * @param {string} fileContent - 분석할 소스 코드 내용 * @param {string} problemName - 문제 이름 (폴더명) - * @param {string} apiKey - OpenAI API 키 + * @param {string} gatewayToken - AI Gateway 인증 토큰 * @returns {Promise<{patterns: string[], description: string}>} */ -export async function generatePatternAnalysis(fileContent, problemName, apiKey) { +export async function generatePatternAnalysis(fileContent, problemName, gatewayToken) { const systemPrompt = `당신은 리트코드 문제 풀이의 알고리즘 패턴을 분석하는 전문가입니다. 주어진 소스 코드를 분석해서, 다음 패턴 목록 중 해당되는 것만 골라주세요. @@ -140,12 +138,9 @@ ${fileContent} 위 코드에 사용된 알고리즘 패턴을 분석해주세요.`; - const response = await fetch("https://api.openai.com/v1/chat/completions", { + const response = await fetch(OPENAI_CHAT_COMPLETIONS_URL, { method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, + headers: aiGatewayHeaders(gatewayToken), body: JSON.stringify({ model: "gpt-5-nano", messages: [ @@ -184,10 +179,10 @@ ${fileContent} * @param {string} fileContent - 분석할 소스 코드 내용 * @param {string} problemName - 문제 이름 (폴더명) * @param {{difficulty: string, categories: string[], intended_approach: string}} problemInfo - 문제 메타 정보 - * @param {string} apiKey - OpenAI API 키 + * @param {string} gatewayToken - AI Gateway 인증 토큰 * @returns {Promise<{matches: boolean, explanation: string}>} */ -export async function generateApproachAnalysis(fileContent, problemName, problemInfo, apiKey) { +export async function generateApproachAnalysis(fileContent, problemName, problemInfo, gatewayToken) { const systemPrompt = `You are an algorithm analysis expert. Determine if code matches the intended approach. Respond with a JSON object in this exact format: @@ -218,12 +213,9 @@ ${truncatedContent} 위 코드가 의도된 접근법과 일치하는지 분석해주세요.`; - const response = await fetch("https://api.openai.com/v1/chat/completions", { + const response = await fetch(OPENAI_CHAT_COMPLETIONS_URL, { method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, + headers: aiGatewayHeaders(gatewayToken), body: JSON.stringify({ model: "gpt-5-nano", messages: [