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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 5 additions & 6 deletions handlers/complexity-analysis.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
* - LLM : actualTime / actualSpace / feedback / suggestion / headerLine 만 책임.
*/

import { aiGatewayHeaders, OPENAI_CHAT_COMPLETIONS_URL } from "../utils/constants.js";

// ── 상수 ──────────────────────────────────────────

const FILE_DELIMITER = "=====";
Expand Down Expand Up @@ -240,20 +242,17 @@ export function composeSolution(modelSol, originalContent) {
};
}

export async function callComplexityAnalysis(fileEntries, apiKey) {
export async function callComplexityAnalysis(fileEntries, gatewayToken) {
const userPrompt = fileEntries
.map(
(f) =>
`${FILE_DELIMITER} ${f.problemName} ${FILE_DELIMITER}\n\`\`\`\n${addLineNumbers(stripComplexityComments(f.content))}\n\`\`\``
)
.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: [
Expand Down
20 changes: 20 additions & 0 deletions handlers/complexity-analysis.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────
Expand Down
4 changes: 2 additions & 2 deletions handlers/internal-dispatch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand All @@ -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 });
}
8 changes: 4 additions & 4 deletions handlers/internal-dispatch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -123,7 +123,7 @@ describe("handleInternalDispatch — 라우팅", () => {
"abc123",
prData,
"fake-token",
"fake-openai"
"fake-gateway-token"
);
expect(postLearningStatus).not.toHaveBeenCalled();
});
Expand Down Expand Up @@ -154,7 +154,7 @@ describe("handleInternalDispatch — 라우팅", () => {
42,
"testuser",
"fake-token",
"fake-openai"
"fake-gateway-token"
);
expect(tagPatterns).not.toHaveBeenCalled();
});
Expand Down Expand Up @@ -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();
Expand Down
24 changes: 12 additions & 12 deletions handlers/webhooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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");

Expand All @@ -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}`);
}
Expand Down Expand Up @@ -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" });
}

Expand All @@ -508,7 +508,7 @@ async function handleIssueCommentEvent(payload, env) {
issue.title,
issue.body,
appToken,
env.OPENAI_API_KEY,
env.AI_GATEWAY_TOKEN,
mention.userRequest
);

Expand Down Expand Up @@ -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" });
}

Expand All @@ -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 // 스레드 답변으로 작성
);
Expand Down
10 changes: 5 additions & 5 deletions handlers/webhooks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
Expand Down Expand Up @@ -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",
};

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

Expand All @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions tests/subrequest-budget.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"시간/공간 복잡도를 분석"
Expand Down Expand Up @@ -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: [
{
Expand Down
8 changes: 4 additions & 4 deletions tests/tag-patterns.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"시간/공간 복잡도를 분석"
Expand Down Expand Up @@ -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(
"시간/공간 복잡도를 분석"
Expand Down Expand Up @@ -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(
"시간/공간 복잡도를 분석"
Expand Down Expand Up @@ -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(
"시간/공간 복잡도를 분석"
Expand Down
29 changes: 29 additions & 0 deletions utils/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>}
*/
export function aiGatewayHeaders(gatewayToken) {
return {
"cf-aig-authorization": `Bearer ${gatewayToken}`,
"Content-Type": "application/json",
...AI_GATEWAY_RETRY_HEADERS,
};
}
Loading