From b9f74570365f6e2b18e2af3afbe0f166d55b93ea Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:24:50 -0400 Subject: [PATCH 1/3] feat: call ai gateway through the workers ai binding --- .github/workflows/deploy.yml | 2 - AGENTS.md | 4 +- handlers/complexity-analysis.js | 33 +++---- handlers/complexity-analysis.test.js | 42 +++++---- handlers/internal-dispatch.js | 4 +- handlers/internal-dispatch.test.js | 10 +- handlers/learning-status.js | 6 +- handlers/tag-patterns.js | 8 +- handlers/webhooks.js | 24 ++--- handlers/webhooks.test.js | 12 ++- tests/fakeAi.js | 21 +++++ tests/subrequest-budget.test.js | 7 +- tests/tag-patterns.test.js | 5 +- utils/constants.js | 29 ------ utils/openai.js | 134 ++++++++++++++------------- utils/prReview.js | 6 +- wrangler.jsonc | 4 + 17 files changed, 175 insertions(+), 176 deletions(-) create mode 100644 tests/fakeAi.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9c00136..88689d1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -31,11 +31,9 @@ jobs: packageManager: bun secrets: | PRIVATE_KEY - AI_GATEWAY_TOKEN INTERNAL_SECRET WEBHOOK_SECRET env: PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} - AI_GATEWAY_TOKEN: ${{ secrets.AI_GATEWAY_TOKEN }} INTERNAL_SECRET: ${{ secrets.INTERNAL_SECRET }} WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }} diff --git a/AGENTS.md b/AGENTS.md index e985ac2..e7a0e1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,13 +79,15 @@ GitHub webhook | ---------------------- | ----------------------------------------------------------------------------------- | | `APP_ID`, `WORKER_URL` | `wrangler.jsonc` 의 `vars` (공개 값) | | `PRIVATE_KEY` | GitHub org secret `APP_PRIVATE_KEY` | -| `AI_GATEWAY_TOKEN` | GitHub org secret `AI_GATEWAY_TOKEN` | +| `AI` | `wrangler.jsonc` 의 AI 바인딩. 같은 계정의 AI Gateway `dalestudy` 를 토큰 없이 부른다 | | `INTERNAL_SECRET` | GitHub 저장소 secret `INTERNAL_SECRET` | | `WEBHOOK_SECRET` | GitHub 저장소 secret `WEBHOOK_SECRET`. GitHub App 웹훅 설정의 secret 과 같은 값이다 | GitHub secret 은 배포할 때마다 Worker secret 으로 들어가므로 `wrangler secret put` 으로 따로 바꾸지 않는다. 다음 배포 때 GitHub 값으로 덮인다. 로컬에서는 `.dev.vars` 에 같은 이름으로 넣는다. `.dev.vars`, `*.pem` 은 커밋하지 않는다. +OpenAI 키는 Worker 에 두지 않는다. AI Gateway `dalestudy` 의 Provider Keys(BYOK)에 저장돼 있고, 게이트웨이는 BYOK only 라 키가 없으면 Unified Billing 으로 넘어가지 않고 실패한다. AI 바인딩은 `wrangler dev` 에서도 원격 게이트웨이를 부르므로 `wrangler login` 이 dalestudy 계정에 되어 있어야 한다. + ## GitHub App 설정 https://github.com/organizations/DaleStudy/settings/apps/dalestudy diff --git a/handlers/complexity-analysis.js b/handlers/complexity-analysis.js index 2647a64..32172ee 100644 --- a/handlers/complexity-analysis.js +++ b/handlers/complexity-analysis.js @@ -10,7 +10,7 @@ * - LLM : actualTime / actualSpace / feedback / suggestion / headerLine 만 책임. */ -import { aiGatewayHeaders, OPENAI_CHAT_COMPLETIONS_URL } from "../utils/constants.js"; +import { createChatCompletion } from "../utils/openai.js"; // ── 상수 ────────────────────────────────────────── @@ -242,7 +242,7 @@ export function composeSolution(modelSol, originalContent) { }; } -export async function callComplexityAnalysis(fileEntries, gatewayToken) { +export async function callComplexityAnalysis(fileEntries, ai) { const userPrompt = fileEntries .map( (f) => @@ -250,27 +250,16 @@ export async function callComplexityAnalysis(fileEntries, gatewayToken) { ) .join("\n\n"); - const response = await fetch(OPENAI_CHAT_COMPLETIONS_URL, { - method: "POST", - headers: aiGatewayHeaders(gatewayToken), - body: JSON.stringify({ - model: "gpt-5-nano", - messages: [ - { role: "system", content: SYSTEM_PROMPT }, - { role: "user", content: userPrompt }, - ], - response_format: { type: "json_object" }, - max_completion_tokens: 5000, - reasoning_effort: "minimal", - }), + const data = await createChatCompletion(ai, { + model: "gpt-5-nano", + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: userPrompt }, + ], + response_format: { type: "json_object" }, + max_completion_tokens: 5000, + reasoning_effort: "minimal", }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`OpenAI API error: ${error}`); - } - - const data = await response.json(); const content = data.choices[0]?.message?.content; if (!content) throw new Error("Empty response from OpenAI"); diff --git a/handlers/complexity-analysis.test.js b/handlers/complexity-analysis.test.js index cd5cf9f..730f3ef 100644 --- a/handlers/complexity-analysis.test.js +++ b/handlers/complexity-analysis.test.js @@ -11,6 +11,7 @@ import { callComplexityAnalysis, renderComplexitySection, } from "./complexity-analysis.js"; +import { createFakeAi } from "../tests/fakeAi.js"; const PLAIN_SOURCE = "function solution() { return 0; }"; @@ -452,6 +453,8 @@ function solution() {}`; // ── callComplexityAnalysis ──────────────────────── describe("callComplexityAnalysis", () => { + const ai = createFakeAi(); + beforeEach(() => { vi.clearAllMocks(); }); @@ -471,7 +474,7 @@ describe("callComplexityAnalysis", () => { { problemName: "valid-parentheses", content: PLAIN_SOURCE }, ]; - const results = await callComplexityAnalysis(fileEntries, "fake-key"); + const results = await callComplexityAnalysis(fileEntries, ai); expect(results).toHaveLength(2); expect(results[0].problemName).toBe("two-sum"); @@ -485,7 +488,7 @@ describe("callComplexityAnalysis", () => { await expect( callComplexityAnalysis( [{ problemName: "two-sum", content: PLAIN_SOURCE }], - "fake-key" + ai ) ).rejects.toThrow("OpenAI API error"); }); @@ -498,7 +501,7 @@ describe("callComplexityAnalysis", () => { await expect( callComplexityAnalysis( [{ problemName: "two-sum", content: PLAIN_SOURCE }], - "fake-key" + ai ) ).rejects.toThrow("Empty response from OpenAI"); }); @@ -513,7 +516,7 @@ describe("callComplexityAnalysis", () => { await expect( callComplexityAnalysis( [{ problemName: "two-sum", content: PLAIN_SOURCE }], - "fake-key" + ai ) ).rejects.toThrow("OpenAI returned invalid JSON"); }); @@ -530,7 +533,7 @@ describe("callComplexityAnalysis", () => { { problemName: "longest-prefix", content: PLAIN_SOURCE }, ]; - const results = await callComplexityAnalysis(fileEntries, "fake-key"); + const results = await callComplexityAnalysis(fileEntries, ai); expect(results).toHaveLength(1); expect(results[0].problemName).toBe("longest-prefix"); @@ -550,7 +553,7 @@ describe("callComplexityAnalysis", () => { function solution() { return 0; }`; await callComplexityAnalysis( [{ problemName: "two-sum", content: source }], - "fake-key" + ai ); expect(captured).toContain("L1: \nL2: \nL3: function solution()"); @@ -558,24 +561,27 @@ function solution() { return 0; }`; 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")]); - }); + it("dalestudy 게이트웨이의 OpenAI chat/completions 로 재시도 설정과 함께 보낸다", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValueOnce( + await makeOpenAIResponse([makeSingleSolutionAnalysis("two-sum")]) + ); await callComplexityAnalysis( [{ problemName: "two-sum", content: PLAIN_SOURCE }], - "fake-key" + ai ); - expect(captured).toMatchObject({ - "cf-aig-authorization": "Bearer fake-key", - "cf-aig-max-attempts": "3", - "cf-aig-retry-delay": "1000", - "cf-aig-backoff": "exponential", + expect(ai.gateway).toHaveBeenCalledWith("dalestudy"); + const request = ai.run.mock.calls[0][0]; + expect(request).toMatchObject({ + provider: "openai", + endpoint: "chat/completions", + config: { maxAttempts: 3, retryDelay: 1000, backoff: "exponential" }, }); + // 인증은 바인딩이 하므로 게이트웨이 토큰을 싣지 않는다 + expect(request.headers).not.toHaveProperty("cf-aig-authorization"); }); }); diff --git a/handlers/internal-dispatch.js b/handlers/internal-dispatch.js index 45e33fe..024afbe 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.AI_GATEWAY_TOKEN + env.AI ); return corsResponse({ handler: "tag-patterns", result }); } @@ -85,7 +85,7 @@ async function handleLearningStatus(payload, appToken, env) { prNumber, username, appToken, - env.AI_GATEWAY_TOKEN + env.AI ); return corsResponse({ handler: "learning-status", result }); } diff --git a/handlers/internal-dispatch.test.js b/handlers/internal-dispatch.test.js index 2ede116..a93a247 100644 --- a/handlers/internal-dispatch.test.js +++ b/handlers/internal-dispatch.test.js @@ -17,6 +17,8 @@ import { tagPatterns } from "./tag-patterns.js"; import { postLearningStatus } from "./learning-status.js"; import { generateGitHubAppToken } from "../utils/github.js"; +const FAKE_AI = { gateway: () => {} }; + const VALID_SECRET = "test-secret-123"; function makeRequest(pathname, { secret, body } = {}) { @@ -88,7 +90,7 @@ describe("handleInternalDispatch — 인증", () => { }); describe("handleInternalDispatch — 라우팅", () => { - const env = { INTERNAL_SECRET: VALID_SECRET, AI_GATEWAY_TOKEN: "fake-gateway-token" }; + const env = { INTERNAL_SECRET: VALID_SECRET, AI: FAKE_AI }; beforeEach(() => { vi.clearAllMocks(); @@ -123,7 +125,7 @@ describe("handleInternalDispatch — 라우팅", () => { "abc123", prData, "fake-token", - "fake-gateway-token" + FAKE_AI ); expect(postLearningStatus).not.toHaveBeenCalled(); }); @@ -154,7 +156,7 @@ describe("handleInternalDispatch — 라우팅", () => { 42, "testuser", "fake-token", - "fake-gateway-token" + FAKE_AI ); expect(tagPatterns).not.toHaveBeenCalled(); }); @@ -200,7 +202,7 @@ describe("handleInternalDispatch — 라우팅", () => { }); describe("handleInternalDispatch — 에러 처리", () => { - const env = { INTERNAL_SECRET: VALID_SECRET, AI_GATEWAY_TOKEN: "fake-gateway-token" }; + const env = { INTERNAL_SECRET: VALID_SECRET, AI: FAKE_AI }; beforeEach(() => { vi.clearAllMocks(); diff --git a/handlers/learning-status.js b/handlers/learning-status.js index 7026c51..083382a 100644 --- a/handlers/learning-status.js +++ b/handlers/learning-status.js @@ -93,7 +93,7 @@ function buildCategoryProgress(categories, solvedProblems) { * @param {number} prNumber * @param {string} username * @param {string} appToken - GitHub App installation token - * @param {string} openaiApiKey + * @param {Ai} ai - Worker 의 AI 바인딩 (env.AI) * @returns {Promise<{ skipped: string }|{ analyzed: number, matched: number }>} */ export async function postLearningStatus( @@ -102,7 +102,7 @@ export async function postLearningStatus( prNumber, username, appToken, - openaiApiKey + ai ) { // 1. 문제 카테고리 메타데이터 조회 const categories = await fetchProblemCategories(repoOwner, repoName, appToken); @@ -167,7 +167,7 @@ export async function postLearningStatus( fileContent, submission.problemName, problemInfo, - openaiApiKey + ai ); if (analysis.usage) { diff --git a/handlers/tag-patterns.js b/handlers/tag-patterns.js index 1abddab..71e416e 100644 --- a/handlers/tag-patterns.js +++ b/handlers/tag-patterns.js @@ -35,7 +35,7 @@ const MAX_CHECK_RUN_OUTPUT_LENGTH = 65535; * @param {string} headSha - PR head commit SHA * @param {object} prData - PR 객체 (draft, labels 포함) * @param {string} appToken - GitHub App installation token - * @param {string} openaiApiKey + * @param {Ai} ai - Worker 의 AI 바인딩 (env.AI) */ export async function tagPatterns( repoOwner, @@ -44,7 +44,7 @@ export async function tagPatterns( headSha, prData, appToken, - openaiApiKey + ai ) { // 2-1. Skip 조건 if (prData.draft === true) { @@ -84,7 +84,7 @@ export async function tagPatterns( const fileEntries = await downloadFileEntries(solutionFiles); // 2-4. 복잡도 분석은 1콜이므로 패턴 루프와 병렬 진행. 실패해도 패턴 분석은 게시. - const complexityPromise = callComplexityAnalysis(fileEntries, openaiApiKey) + const complexityPromise = callComplexityAnalysis(fileEntries, ai) .catch((err) => { console.error(`[tagPatterns] complexity analysis failed: ${err.message}`); return []; @@ -97,7 +97,7 @@ export async function tagPatterns( const analysis = await generatePatternAnalysis( fe.content, fe.problemName, - openaiApiKey + ai ); analyses.push({ fileEntry: fe, analysis }); } catch (error) { diff --git a/handlers/webhooks.js b/handlers/webhooks.js index 68dd52d..4d54796 100644 --- a/handlers/webhooks.js +++ b/handlers/webhooks.js @@ -258,7 +258,7 @@ async function handlePullRequestEvent(payload, env, ctx) { } // AI 핸들러들을 별도 Worker 호출로 디스패치 (각각 독립적인 subrequest 예산) - if (env.AI_GATEWAY_TOKEN && env.INTERNAL_SECRET && env.WORKER_URL) { + if (env.AI && env.INTERNAL_SECRET && env.WORKER_URL) { const baseUrl = env.WORKER_URL; const dispatchHeaders = { @@ -298,7 +298,7 @@ async function handlePullRequestEvent(payload, env, ctx) { ); console.log(`[handlePullRequestEvent] Dispatched 2 AI handlers for PR #${prNumber}`); - } else if (env.AI_GATEWAY_TOKEN) { + } else if (env.AI) { // INTERNAL_SECRET/WORKER_URL 미설정 시 기존 방식으로 폴백 (동일 invocation에서 순차 실행) console.warn("[handlePullRequestEvent] INTERNAL_SECRET or WORKER_URL not set, running handlers in-process"); @@ -310,14 +310,14 @@ async function handlePullRequestEvent(payload, env, ctx) { pr.head.sha, pr, appToken, - env.AI_GATEWAY_TOKEN + env.AI ); } catch (error) { console.error(`[handlePullRequestEvent] tagPatterns failed: ${error.message}`); } try { - await postLearningStatus(repoOwner, repoName, prNumber, pr.user.login, appToken, env.AI_GATEWAY_TOKEN); + await postLearningStatus(repoOwner, repoName, prNumber, pr.user.login, appToken, env.AI); } catch (error) { console.error(`[handlePullRequestEvent] learningStatus failed: ${error.message}`); } @@ -481,9 +481,9 @@ async function handleIssueCommentEvent(payload, env) { // AI 코드 리뷰 요청 처리 console.log(`AI review requested for PR #${prNumber}${mention.userRequest ? ` - Request: ${mention.userRequest}` : ""}`); - // AI_GATEWAY_TOKEN 확인 - if (!env.AI_GATEWAY_TOKEN) { - console.log("AI_GATEWAY_TOKEN not configured"); + // AI 바인딩 확인 + if (!env.AI) { + console.log("AI binding not configured"); return corsResponse({ message: "AI review not configured" }); } @@ -506,7 +506,7 @@ async function handleIssueCommentEvent(payload, env) { issue.title, issue.body, appToken, - env.AI_GATEWAY_TOKEN, + env.AI, mention.userRequest ); @@ -551,9 +551,9 @@ async function handlePullRequestReviewCommentEvent(payload, env) { console.log(`AI review requested for PR #${prNumber} (review comment)${mention.userRequest ? ` - Request: ${mention.userRequest}` : ""}`); - // AI_GATEWAY_TOKEN 확인 - if (!env.AI_GATEWAY_TOKEN) { - console.log("AI_GATEWAY_TOKEN not configured"); + // AI 바인딩 확인 + if (!env.AI) { + console.log("AI binding not configured"); return corsResponse({ message: "AI review not configured" }); } @@ -579,7 +579,7 @@ async function handlePullRequestReviewCommentEvent(payload, env) { pullRequest.title, pullRequest.body, appToken, - env.AI_GATEWAY_TOKEN, + env.AI, mention.userRequest, comment.id // 스레드 답변으로 작성 ); diff --git a/handlers/webhooks.test.js b/handlers/webhooks.test.js index 1634572..f183a80 100644 --- a/handlers/webhooks.test.js +++ b/handlers/webhooks.test.js @@ -42,6 +42,8 @@ import { import { tagPatterns } from "./tag-patterns.js"; import { postLearningStatus } from "./learning-status.js"; +const FAKE_AI = { gateway: () => {} }; + function makeRequest(eventType, payload) { return new Request("https://example.com/webhooks", { method: "POST", @@ -286,10 +288,10 @@ describe("handlePullRequestEvent — AI 핸들러 디스패치", () => { }); }); - it("AI_GATEWAY_TOKEN, INTERNAL_SECRET, WORKER_URL 이 모두 설정되면 ctx.waitUntil 로 self-fetch 2 회를 디스패치한다", async () => { + it("AI 바인딩, INTERNAL_SECRET, WORKER_URL 이 모두 설정되면 ctx.waitUntil 로 self-fetch 2 회를 디스패치한다", async () => { const ctx = makeCtx(); const env = { - AI_GATEWAY_TOKEN: "fake-gateway-token", + AI: FAKE_AI, INTERNAL_SECRET: "fake-secret", WORKER_URL: "https://worker.test", }; @@ -321,7 +323,7 @@ describe("handlePullRequestEvent — AI 핸들러 디스패치", () => { it("INTERNAL_SECRET 이 없으면 in-process 핸들러 호출로 폴백한다", async () => { const ctx = makeCtx(); const env = { - AI_GATEWAY_TOKEN: "fake-gateway-token", + AI: FAKE_AI, WORKER_URL: "https://worker.test", }; @@ -345,7 +347,7 @@ describe("handlePullRequestEvent — AI 핸들러 디스패치", () => { it("WORKER_URL 이 없으면 in-process 핸들러 호출로 폴백한다", async () => { const ctx = makeCtx(); const env = { - AI_GATEWAY_TOKEN: "fake-gateway-token", + AI: FAKE_AI, INTERNAL_SECRET: "fake-secret", }; @@ -361,7 +363,7 @@ describe("handlePullRequestEvent — AI 핸들러 디스패치", () => { expect(postLearningStatus).toHaveBeenCalledTimes(1); }); - it("AI_GATEWAY_TOKEN 가 없으면 디스패치도 핸들러 호출도 하지 않는다", async () => { + it("AI 바인딩이 없으면 디스패치도 핸들러 호출도 하지 않는다", async () => { const ctx = makeCtx(); const env = { INTERNAL_SECRET: "fake-secret", diff --git a/tests/fakeAi.js b/tests/fakeAi.js new file mode 100644 index 0000000..bda8728 --- /dev/null +++ b/tests/fakeAi.js @@ -0,0 +1,21 @@ +import { vi } from "vitest"; + +export const FAKE_AI_GATEWAY_URL = + "https://gateway.test/openai/chat/completions"; + +/** + * env.AI 바인딩 대역. + * + * gateway().run() 요청을 globalThis.fetch 로 넘긴다. OpenAI 응답은 fetch 모킹으로 + * 흉내 내고, subrequest 예산 테스트의 호출 수에도 그대로 포함된다. + */ +export function createFakeAi() { + const run = vi.fn((request) => + fetch(FAKE_AI_GATEWAY_URL, { + method: "POST", + headers: request.headers, + body: JSON.stringify(request.query), + }) + ); + return { gateway: vi.fn(() => ({ run })), run }; +} diff --git a/tests/subrequest-budget.test.js b/tests/subrequest-budget.test.js index 894a136..a77ce9b 100644 --- a/tests/subrequest-budget.test.js +++ b/tests/subrequest-budget.test.js @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { tagPatterns } from "../handlers/tag-patterns.js"; import { postLearningStatus } from "../handlers/learning-status.js"; +import { createFakeAi } from "./fakeAi.js"; const REPO_OWNER = "DaleStudy"; const REPO_NAME = "leetcode-study"; @@ -9,7 +10,7 @@ const PR_NUMBER = 42; const HEAD_SHA = "head-sha"; const USERNAME = "testuser"; const APP_TOKEN = "fake-app-token"; -const OPENAI_KEY = "fake-openai-key"; +const AI = createFakeAi(); function makeSolutionFiles(count) { return Array.from({ length: count }, (_, i) => ({ @@ -137,7 +138,7 @@ describe("subrequest 예산 — 핸들러별 invocation", () => { HEAD_SHA, prData, APP_TOKEN, - OPENAI_KEY + AI ); const fetchCount = globalThis.fetch.mock.calls.length; @@ -258,7 +259,7 @@ describe("subrequest 예산 — 핸들러별 invocation", () => { PR_NUMBER, USERNAME, APP_TOKEN, - OPENAI_KEY + AI ); const fetchCount = globalThis.fetch.mock.calls.length; diff --git a/tests/tag-patterns.test.js b/tests/tag-patterns.test.js index b8bea5a..c87f51b 100644 --- a/tests/tag-patterns.test.js +++ b/tests/tag-patterns.test.js @@ -9,13 +9,14 @@ vi.mock("../utils/github.js", () => ({ })); import { tagPatterns } from "../handlers/tag-patterns.js"; +import { createFakeAi } from "./fakeAi.js"; const REPO_OWNER = "DaleStudy"; const REPO_NAME = "leetcode-study"; const PR_NUMBER = 42; const HEAD_SHA = "head-sha"; const APP_TOKEN = "fake-app-token"; -const OPENAI_KEY = "fake-openai-key"; +const AI = createFakeAi(); const LINK_COMMENT_MARKER = ""; const LEGACY_COMPLEXITY_MARKER = ""; @@ -69,7 +70,7 @@ function runTagPatterns(prData = makePrData()) { return tagPatterns( REPO_OWNER, REPO_NAME, PR_NUMBER, HEAD_SHA, prData, - APP_TOKEN, OPENAI_KEY + APP_TOKEN, AI ); } diff --git a/utils/constants.js b/utils/constants.js index 8b7f4ee..e6f762b 100644 --- a/utils/constants.js +++ b/utils/constants.js @@ -12,32 +12,3 @@ export const WEBHOOK_URL = "https://github.dalestudy.com/webhooks"; // Organization/Repository export const ALLOWED_ORG = "DaleStudy"; export const ALLOWED_REPO = "leetcode-study"; - -// 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 796d2ad..82d0468 100644 --- a/utils/openai.js +++ b/utils/openai.js @@ -1,7 +1,42 @@ /** * OpenAI API 통합 (AI Gateway 경유) */ -import { aiGatewayHeaders, OPENAI_CHAT_COMPLETIONS_URL } from "./constants.js"; + +// Worker 와 같은 계정에 있는 게이트웨이라 AI 바인딩으로 부르고, 인증 토큰은 쓰지 않는다. +// OpenAI 키는 게이트웨이에 BYOK 로 저장돼 있다. +const AI_GATEWAY_ID = "dalestudy"; + +// 게이트웨이에 재시도를 맡긴다. Worker 안에서 재시도하면 CPU 예산을 깎지만, +// 엣지에서 재시도하면 응답을 기다리는 시간일 뿐이다. +const AI_GATEWAY_RETRY_CONFIG = { + maxAttempts: 3, + retryDelay: 1000, + backoff: "exponential", +}; + +/** + * AI Gateway 를 거쳐 OpenAI Chat Completions 를 호출한다. + * + * @param {Ai} ai - Worker 의 AI 바인딩 (env.AI) + * @param {object} body - Chat Completions 요청 본문 + * @returns {Promise} 응답 JSON + */ +export async function createChatCompletion(ai, body) { + const response = await ai.gateway(AI_GATEWAY_ID).run({ + provider: "openai", + endpoint: "chat/completions", + headers: { "Content-Type": "application/json" }, + query: body, + config: AI_GATEWAY_RETRY_CONFIG, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`OpenAI API error: ${error}`); + } + + return response.json(); +} /** * PR diff를 분석하여 AI 코드 리뷰 생성 @@ -9,7 +44,7 @@ import { aiGatewayHeaders, OPENAI_CHAT_COMPLETIONS_URL } from "./constants.js"; * @param {string} prDiff - PR의 diff 내용 * @param {string} prTitle - PR 제목 * @param {string} prBody - PR 본문 - * @param {string} gatewayToken - AI Gateway 인증 토큰 + * @param {Ai} ai - Worker 의 AI 바인딩 (env.AI) * @param {string} userRequest - 사용자의 구체적인 요청 (선택사항) * @returns {Promise} AI가 생성한 리뷰 댓글 (마크다운) */ @@ -17,7 +52,7 @@ export async function generateCodeReview( prDiff, prTitle, prBody, - gatewayToken, + ai, userRequest = null ) { // userRequest가 있으면 Q&A 모드, 없으면 전체 리뷰 모드 @@ -63,26 +98,15 @@ ${prDiff} userPrompt += `\n이 풀 리퀘스트를 리뷰해주세요.`; } - const response = await fetch(OPENAI_CHAT_COMPLETIONS_URL, { - method: "POST", - headers: aiGatewayHeaders(gatewayToken), - body: JSON.stringify({ - model: "gpt-5-nano", - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: userPrompt }, - ], - max_completion_tokens: 3000, - reasoning_effort: "minimal", - }), + const data = await createChatCompletion(ai, { + model: "gpt-5-nano", + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + max_completion_tokens: 3000, + reasoning_effort: "minimal", }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`OpenAI API error: ${error}`); - } - - const data = await response.json(); return data.choices[0]?.message?.content || "Failed to generate review"; } @@ -91,10 +115,10 @@ ${prDiff} * * @param {string} fileContent - 분석할 소스 코드 내용 * @param {string} problemName - 문제 이름 (폴더명) - * @param {string} gatewayToken - AI Gateway 인증 토큰 + * @param {Ai} ai - Worker 의 AI 바인딩 (env.AI) * @returns {Promise<{patterns: string[], description: string}>} */ -export async function generatePatternAnalysis(fileContent, problemName, gatewayToken) { +export async function generatePatternAnalysis(fileContent, problemName, ai) { const systemPrompt = `당신은 리트코드 문제 풀이의 알고리즘 패턴을 분석하는 전문가입니다. 주어진 소스 코드를 분석해서, 다음 패턴 목록 중 해당되는 것만 골라주세요. @@ -138,27 +162,16 @@ ${fileContent} 위 코드에 사용된 알고리즘 패턴을 분석해주세요.`; - const response = await fetch(OPENAI_CHAT_COMPLETIONS_URL, { - method: "POST", - headers: aiGatewayHeaders(gatewayToken), - body: JSON.stringify({ - model: "gpt-5-nano", - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: userPrompt }, - ], - response_format: { type: "json_object" }, - max_completion_tokens: 2000, - reasoning_effort: "minimal", - }), + const data = await createChatCompletion(ai, { + model: "gpt-5-nano", + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + response_format: { type: "json_object" }, + max_completion_tokens: 2000, + reasoning_effort: "minimal", }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`OpenAI API error: ${error}`); - } - - const data = await response.json(); const content = data.choices[0]?.message?.content; if (!content) { @@ -179,10 +192,10 @@ ${fileContent} * @param {string} fileContent - 분석할 소스 코드 내용 * @param {string} problemName - 문제 이름 (폴더명) * @param {{difficulty: string, categories: string[], intended_approach: string}} problemInfo - 문제 메타 정보 - * @param {string} gatewayToken - AI Gateway 인증 토큰 + * @param {Ai} ai - Worker 의 AI 바인딩 (env.AI) * @returns {Promise<{matches: boolean, explanation: string}>} */ -export async function generateApproachAnalysis(fileContent, problemName, problemInfo, gatewayToken) { +export async function generateApproachAnalysis(fileContent, problemName, problemInfo, ai) { const systemPrompt = `You are an algorithm analysis expert. Determine if code matches the intended approach. Respond with a JSON object in this exact format: @@ -213,27 +226,16 @@ ${truncatedContent} 위 코드가 의도된 접근법과 일치하는지 분석해주세요.`; - const response = await fetch(OPENAI_CHAT_COMPLETIONS_URL, { - method: "POST", - headers: aiGatewayHeaders(gatewayToken), - body: JSON.stringify({ - model: "gpt-5-nano", - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: userPrompt }, - ], - response_format: { type: "json_object" }, - max_completion_tokens: 1500, - reasoning_effort: "minimal", - }), + const data = await createChatCompletion(ai, { + model: "gpt-5-nano", + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + response_format: { type: "json_object" }, + max_completion_tokens: 1500, + reasoning_effort: "minimal", }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`OpenAI API error: ${error}`); - } - - const data = await response.json(); const content = data.choices[0]?.message?.content; if (!content) { diff --git a/utils/prReview.js b/utils/prReview.js index bde8a12..628654f 100644 --- a/utils/prReview.js +++ b/utils/prReview.js @@ -124,7 +124,7 @@ export async function addReactionToComment( * @param {string} prTitle - PR 제목 * @param {string} prBody - PR 본문 * @param {string} githubToken - GitHub 토큰 - * @param {string} openaiApiKey - OpenAI API 키 + * @param {Ai} ai - Worker 의 AI 바인딩 (env.AI) * @param {string} userRequest - 사용자의 구체적인 요청 (선택사항) * @param {number} replyToCommentId - 스레드 답변으로 작성할 댓글 ID (선택사항) */ @@ -135,7 +135,7 @@ export async function performAIReview( prTitle, prBody, githubToken, - openaiApiKey, + ai, userRequest = null, replyToCommentId = null ) { @@ -156,7 +156,7 @@ export async function performAIReview( prDiff, prTitle, prBody, - openaiApiKey, + ai, userRequest ); diff --git a/wrangler.jsonc b/wrangler.jsonc index be8c081..1a44499 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -9,6 +9,10 @@ "APP_ID": "1067270", "WORKER_URL": "https://github.dalestudy.workers.dev", }, + // 같은 계정의 AI Gateway(dalestudy)를 토큰 없이 부른다 + "ai": { + "binding": "AI", + }, "observability": { "logs": { "enabled": true, From cc5db32cc8d0d37674ec78ff95f5eeb7edba1a85 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:04:09 -0400 Subject: [PATCH 2/3] fix: pass gateway retry settings as headers --- handlers/complexity-analysis.test.js | 8 ++++++-- utils/openai.js | 11 +++++------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/handlers/complexity-analysis.test.js b/handlers/complexity-analysis.test.js index 730f3ef..c96537b 100644 --- a/handlers/complexity-analysis.test.js +++ b/handlers/complexity-analysis.test.js @@ -561,7 +561,7 @@ function solution() { return 0; }`; expect(captured).not.toContain("SC: O(1)"); }); - it("dalestudy 게이트웨이의 OpenAI chat/completions 로 재시도 설정과 함께 보낸다", async () => { + it("dalestudy 게이트웨이의 OpenAI chat/completions 로 재시도 헤더와 함께 보낸다", async () => { globalThis.fetch = vi .fn() .mockResolvedValueOnce( @@ -578,7 +578,11 @@ function solution() { return 0; }`; expect(request).toMatchObject({ provider: "openai", endpoint: "chat/completions", - config: { maxAttempts: 3, retryDelay: 1000, backoff: "exponential" }, + headers: { + "cf-aig-max-attempts": 3, + "cf-aig-retry-delay": 1000, + "cf-aig-backoff": "exponential", + }, }); // 인증은 바인딩이 하므로 게이트웨이 토큰을 싣지 않는다 expect(request.headers).not.toHaveProperty("cf-aig-authorization"); diff --git a/utils/openai.js b/utils/openai.js index 82d0468..f1c32bc 100644 --- a/utils/openai.js +++ b/utils/openai.js @@ -8,10 +8,10 @@ const AI_GATEWAY_ID = "dalestudy"; // 게이트웨이에 재시도를 맡긴다. Worker 안에서 재시도하면 CPU 예산을 깎지만, // 엣지에서 재시도하면 응답을 기다리는 시간일 뿐이다. -const AI_GATEWAY_RETRY_CONFIG = { - maxAttempts: 3, - retryDelay: 1000, - backoff: "exponential", +const AI_GATEWAY_RETRY_HEADERS = { + "cf-aig-max-attempts": 3, + "cf-aig-retry-delay": 1000, + "cf-aig-backoff": "exponential", }; /** @@ -25,9 +25,8 @@ export async function createChatCompletion(ai, body) { const response = await ai.gateway(AI_GATEWAY_ID).run({ provider: "openai", endpoint: "chat/completions", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...AI_GATEWAY_RETRY_HEADERS }, query: body, - config: AI_GATEWAY_RETRY_CONFIG, }); if (!response.ok) { From d80256c07b4da6876e41b81e9e715547e7f8bc50 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:28:31 -0400 Subject: [PATCH 3/3] test: disable remote bindings in the workers test pool --- vitest.config.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/vitest.config.js b/vitest.config.js index 2ddddcd..ba344e1 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -5,6 +5,9 @@ export default defineConfig({ plugins: [ cloudflareTest({ wrangler: { configPath: "./wrangler.jsonc" }, + // AI 바인딩은 원격 전용이라 켜 두면 로그인 없는 CI 에서 풀이 시작하지 못한다. + // 테스트는 tests/fakeAi.js 로 바인딩을 대신한다. + remoteBindings: false, }), ], test: {