릴레이 서버로 모델 호출 라우팅
릴레이 서버가 필요한 이유
Editor AI SDK Playground에서는 모델을 선택하거나 API 키를 입력하는 AI 관련 설정을 하지 않았는데도, LLM을 통해 문서가 편집되는 것을 볼 수 있습니다. 브라우저와 모델 사이에서 키를 보관하고, Editor AI SDK를 통합한 애플리케이션(이하 호스트 애플리케이션)을 대신해 공급자와 통신하는 구성 요소가 있다는 뜻입니다. 그 구성 요소가 릴레이 서버이며, 개발자의 로컬 환경을 벗어나는 모든 Editor AI SDK 통합에는 릴레이 서버가 필요합니다.
빠른 시작의 createMessage()는 direct 모드로 동작합니다. 호스트 애플리케이션이 브라우저에서 테스트용 API 키로
공급자 API를 직접 호출하는 방식으로, 백엔드 없이 개인 개발 환경에서 흐름을 확인하기에는 편리하지만 키가 페이지와 브라우저
개발자 도구에 그대로 노출됩니다. relay 모드는 이 한 지점만 바꿉니다. 키와 공급자 호출을 백엔드의 릴레이 서버로 옮기고,
createMessage()는 공급자 API를 직접 호출하지 않고 대신 릴레이 서버를 호출합니다. tool loop, 도구 검증, 사용자 승인,
tool.execute()는 그대로 유지됩니다.
| direct 모드 | relay 모드 | |
|---|---|---|
| 공급자 API를 호출하는 주체 | 호스트 애플리케이션(브라우저) | 릴레이 서버 |
| API 키의 위치 | 페이지(입력란과 JavaScript) | 릴레이 서버의 환경 |
createMessage()의 호출 대상 | 공급자 API | 릴레이 서버 엔드포인트 |
| 정책(모델 허용 목록, 인증, 할당량) | 적용할 지점 없음 | 릴레이 서버 |
| 용도 | 로컬 테스트 | 공유 환경과 프로덕션 배포 |
이 가이드는 릴레이 서버가 무엇인지, 호스트 애플리케이션의 어떤 역할을 대신하는지, 요청 하나가 어떤 경로로 처리되는지, 그리고 직접 확장할 수 있는 작은 Node.js 릴레이 서버를 어떻게 실행하는지 설명합니다. 필드 단위 스펙은 릴레이 서버 요청/응답 스펙에서 다룹니다.
릴레이 서버란 무엇인가
릴레이 서버는 애플리케이션 백엔드에서 운영하는 작은 HTTP 서비스입니다. 호스트 애플리케이션은 모델 공급자(provider)에게 직접 보내던 요청을 릴레이 서버로 보내고, 릴레이 서버는 보관 중인 인증 정보를 덧붙여 공급자에게 전달한 뒤 공급자의 응답을 변경하지 않고 그대로 반환합니다.
이 역할에서 릴레이 서버를 단순하게 유지하는 세 가지 특징이 나옵니다.
- API 키를 백엔드 릴레이 서버가 관리하므로 브라우저에는 존재하지 않습니다. 공급자 키는 릴레이 서버의 환경에만 존재합니다. 브라우저는 키를 전달받지 않으므로 JavaScript, 개발자 도구, 저장된 페이지 어디에서도 키가 노출되지 않습니다.
- 정책을 릴레이 서버가 결정합니다. 모든 모델 호출이 릴레이 서버를 경유하므로, 허용할 공급자와 모델, 호출 주체와 호출량을 결정하는 지점은 릴레이 서버 하나입니다.
- 대화 상태를 보관하지 않습니다. 매 요청에 공급자가 필요로 하는 모든 정보가 담겨 있습니다. 릴레이 서버는 대화 기록을 저장하지 않고 도구도 실행하지 않으므로, 상태 없는(stateless) HTTP 서비스처럼 확장할 수 있고 하나의 릴레이 서버가 여러 애플리케이션을 함께 담당할 수 있습니다.
호스트 애플리케이션과 릴레이 서버의 책임
Editor AI SDK의 도구는 Thinkfree Office 편집기에 열려 있는 문서를 대상으로 동작합니다. 그 문서에 접근할 수 있는 것은 브라우저뿐이므로
tool.execute()는 서버로 옮길 수 없습니다. 따라서 릴레이 서버는 정확히 한 가지, 공급자 호출만 넘겨받고 tool loop의 나머지는
호스트 애플리케이션에 그대로 둡니다.
| 관심사 | 호스트 애플리케이션(브라우저) | 릴레이 서버(백엔드) | LLM 공급자 |
|---|---|---|---|
| 대화 기록과 현재 프롬프트 | 보관하고 전송 | 전달 | 해석 |
getTools()의 도구 카탈로그 | 선택하여 스키마 전송 | 전달 | tool call 결정 |
tool.execute()와 사용자 승인 | 실행 | 관여하지 않음 | 관여하지 않음 |
| 공급자 키 | 존재하지 않음 | 보관 | 검증 |
| 공급자·모델 정책, 호출자 인증, 할당량 | 적용 불가 | 적용 | 관여하지 않음 |
| 응답 | content와 stop_reason 사용 | 변경 없이 전달 | 생성 |
Playground의 기본 연결인 Thinkfree Relay Server가 바로 이 구조를 Thinkfree의 릴레이 서버로 구현한 것입니다. 페이지에는 키가 없고, 사용할 모델은 릴레이 서버가 결정합니다. 같은 메뉴의 direct 모드 항목은 로컬 테스트 용도입니다.
요청 처리 흐름
호스트 애플리케이션은 모델을 호출할 때마다 두 가지 정보를 만들어 하나의 JSON 본문에 함께 담아 보냅니다.
tfAgentRelay는 이 turn의 의미를 담습니다. 요청·turn 식별자, 선택한 공급자와 모델, 시스템 프롬프트, 대화 기록(transcript), 현재 입력, 도구 카탈로그가 여기에 포함됩니다.tfAgentTransport는 실행할 HTTP 요청을 담습니다. 공급자 어댑터, 모델, 스트리밍 여부, 그리고 공급자에게 바로 전달할 수 있는 요청 본문입니다. 이 본문은 호스트 애플리케이션이 direct 모드에서 공급자 API를 호출할 때 전송하는 페이로드(JSON)와 같습니다.
요청 본문을 두 부분으로 나누는 이유는 릴레이 서버가 서로 다른 두 가지 일을 해야 하기 때문입니다. 정책을 적용하려면 요청이 어떤
공급자와 모델을 향하는지 알아야 하고, 요청을 전달하려면 공급자가 그대로 받을 수 있는 본문이 필요합니다. tfAgentRelay가
앞의 역할을, tfAgentTransport가 뒤의 역할을 맡습니다. 그 결과 릴레이 서버는 공급자마다 다른 요청 형식을 해석하지 않고도
정책을 적용할 수 있습니다.
릴레이 서버는 요청을 다음 순서로 처리합니다. 먼저 tfAgentTransport에 적힌 공급자와 모델을 tfAgentRelay의 값과 대조하고,
두 값이 다르면 조작된 요청으로 판단해 거부합니다. 한 모델을 요청하는 것처럼 보이면서 실제로는 다른 모델을 호출하는 시도를
차단하기 위한 검사입니다. 다음으로 허용 목록을 적용해 공급자와 모델을 확정하고, 인증 정보를 덧붙여 tfAgentTransport.body를
공급자에게 전송합니다. 마지막으로 공급자의 응답을 변경 없이 호스트 애플리케이션에 반환합니다. 스트리밍을 요청하지 않았다면
하나의 JSON 본문으로, 요청했다면 도착하는 조각 단위로 전달합니다. 응답 형식이 바뀌지 않으므로 createMessage()는 direct
모드에서 처리하던 { content, stop_reason } 형태를 그대로 받습니다.
- 사용자가 chat UI에서 변경을 요청합니다.
- 호스트 애플리케이션이 릴레이 요청을
POST /ai-agent/relay로 전송합니다. - 릴레이 서버가 요청 본문을 검증하고, 공급자와 모델을 허용 목록과 대조하여 결정한 뒤 공급자 키를 덧붙입니다.
- 릴레이 서버가 공급자용 요청 본문을 공급자에게 전송합니다.
- 공급자가 응답합니다. 이 예에서는 tool call입니다.
- 릴레이 서버가 공급자 응답을 변경 없이 반환합니다.
- 호스트 애플리케이션이 tool call을 검증하고 사용자에게 승인을 요청합니다.
- 호스트 애플리케이션이 Editor SDK를 통해
tool.execute()를 실행하고, 같은 페이지의 문서가 갱신됩니다. - 도구 결과가 대화 기록에 추가됩니다.
- 호스트 애플리케이션이 다음 요청을 전송합니다. 모델이 더 이상 도구를 요청하지 않으면 loop가 종료됩니다. 이 페이지 예제가 쓰는 Claude 형태에서는
stop_reason이tool_use가 아닌 경우입니다.
7~9단계는 릴레이 서버를 거치지 않습니다. API 키는 백엔드로 이동했지만, 문서 작업은 브라우저에 그대로 남습니다.
Node.js 릴레이 서버 스켈레톤
아래 서버는 자신의 키로 빠른 시작의 loop를 실행할 수 있을 만큼 완전하면서도, 한 번에 읽을 수 있을 만큼 작습니다. 외부
의존성이 없습니다. Node.js 22 이상을 설치하고 파일을 relay-server.mjs로 저장합니다.
// relay-server.mjs - a minimal relay for Editor AI SDK. Node.js 22 or later, no dependencies.
// The browser keeps the conversation and executes tools. This server only holds the provider
// key, enforces which vendor and model may be used, and forwards one request at a time.
import http from "node:http";
const PORT = Number(process.env.RELAY_SERVER_PORT ?? 8787);
const RELAY_PATH = "/ai-agent/relay";
const ORIGIN = process.env.RELAY_SERVER_CORS_ORIGIN ?? "http://localhost:3000";
const BODY_LIMIT = 2 * 1024 * 1024;
// Policy: one entry per vendor you operate. `models` is the allowlist; the first one is the default.
const list = (value) => (value ?? "").split(",").map((s) => s.trim()).filter(Boolean);
const VENDORS = {
claude: {
aiProvider: "claude", apiKey: process.env.CLAUDE_API_KEY, models: list(process.env.CLAUDE_ALLOWED_MODELS),
request: (model, stream, body, key) => ({
url: process.env.CLAUDE_ENDPOINT ?? "https://api.anthropic.com/v1/messages",
headers: { "x-api-key": key, "anthropic-version": "2023-06-01" },
body: { ...body, model },
}),
},
google: {
aiProvider: "google", apiKey: process.env.GEMINI_API_KEY, models: list(process.env.GEMINI_ALLOWED_MODELS),
request: (model, stream, body, key) => ({
url: `${process.env.GEMINI_ENDPOINT ?? "https://generativelanguage.googleapis.com/v1beta/models/"}`
+ `${encodeURIComponent(model)}:${stream ? "streamGenerateContent?alt=sse&" : "generateContent?"}key=${encodeURIComponent(key)}`,
headers: {},
body,
}),
},
openai: {
aiProvider: "openai", apiKey: process.env.OPENAI_API_KEY, models: list(process.env.OPENAI_ALLOWED_MODELS),
request: (model, stream, body, key) => ({
url: process.env.OPENAI_ENDPOINT ?? "https://api.openai.com/v1/chat/completions",
headers: { authorization: `Bearer ${key}` },
body: { ...body, model },
}),
},
};
class RelayError extends Error {
constructor(status, code, message, details = null) { super(message); Object.assign(this, { status, code, details }); }
}
const fail = (status, code, message, details) => { throw new RelayError(status, code, message, details); };
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
// 1. Validate the envelope: both halves must be present and must describe the same request.
function validate(body) {
if (!isObject(body)) fail(400, "relay-request-invalid", "Relay request body must be a JSON object.");
const relay = body.tfAgentRelay, transport = body.tfAgentTransport;
if (!isObject(relay)) fail(400, "relay-request-invalid", "Relay request body.tfAgentRelay must be a JSON object.");
if (!isObject(transport)) fail(400, "relay-request-invalid", "Relay request body.tfAgentTransport must be a JSON object.");
if (!isObject(transport.body)) fail(400, "relay-request-invalid", "Relay request body.tfAgentTransport.body must be a JSON object.");
if (typeof transport.adapterId !== "string" || !transport.adapterId.trim()) fail(400, "relay-transport-adapter-missing", "Relay transport adapterId is required.");
if (typeof transport.aiProvider !== "string" || !transport.aiProvider.trim()) fail(400, "relay-transport-provider-missing", "Relay transport aiProvider is required.");
if (typeof transport.stream !== "boolean") fail(400, "relay-transport-stream-invalid", "Relay transport stream must be a boolean.");
const semantic = isObject(relay.aiProviderConfig) ? relay.aiProviderConfig : {};
if (semantic.aiProvider && semantic.aiProvider !== transport.aiProvider) fail(400, "relay-provider-mismatch", "Relay semantic aiProvider and transport aiProvider must match.");
if (semantic.model && transport.model && semantic.model !== transport.model) fail(400, "relay-model-mismatch", "Relay semantic model and transport model must match.");
return { relay, transport };
}
// 2. Apply the policy: pick the vendor route and a model that the route allows.
function resolve(relay, transport) {
const requested = (transport.vendor ?? relay.relayVendor ?? "").toLowerCase();
const route = requested
? VENDORS[requested]
: Object.values(VENDORS).find((v) => v.aiProvider === transport.aiProvider && v.models.length);
if (!route || route.aiProvider !== transport.aiProvider) fail(403, "relay-vendor-not-allowed", `Relay vendor "${requested || transport.aiProvider}" is not allowed.`);
if (!route.apiKey) fail(500, "provider-config-missing", `API key for "${transport.aiProvider}" is not configured on the relay server.`);
const model = transport.model?.trim() || route.models[0];
if (!route.models.includes(model)) fail(403, "relay-model-not-allowed", `Relay model "${model}" is not allowed.`, { model });
return route.request(model, transport.stream, transport.body, route.apiKey);
}
async function readJson(req) {
const chunks = []; let size = 0;
for await (const chunk of req) {
if ((size += chunk.length) > BODY_LIMIT) fail(413, "relay-request-too-large", "Relay request body exceeded the configured size limit.");
chunks.push(chunk);
}
const text = Buffer.concat(chunks).toString("utf8").trim();
if (!text) fail(400, "relay-request-empty", "Relay request body is required.");
try { return JSON.parse(text); } catch { fail(400, "relay-request-json-invalid", "Relay request body must be valid JSON."); }
}
const json = (res, status, payload) => { res.writeHead(status, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify(payload) + "\n"); };
const error = (res, e, ctx = {}) => json(res, e.status ?? 500, {
error: { code: e.code ?? "relay-internal-error", message: e.message, details: e.details ?? null },
relay: { requestId: ctx.requestId ?? null, turnId: ctx.turnId ?? null, upstreamStatus: e.details?.upstreamStatus ?? null },
});
// 3. Forward and pass the provider response through unchanged - JSON as one body, SSE chunk by chunk.
async function proxy(upstream, res) {
if (!upstream.ok) fail(502, "upstream-request-failed", `Upstream provider request failed (${upstream.status}).`, { upstreamStatus: upstream.status });
const type = upstream.headers.get("content-type") ?? "application/json";
res.writeHead(200, type.includes("text/event-stream")
? { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache, no-transform", connection: "keep-alive" }
: { "content-type": type });
for await (const chunk of upstream.body) res.write(chunk);
res.end();
}
http.createServer(async (req, res) => {
const origin = req.headers.origin;
if (origin && origin !== ORIGIN) return error(res, new RelayError(403, "cors-origin-not-allowed", "Request Origin is not allowed by the Relay Server CORS policy."));
res.setHeader("access-control-allow-origin", ORIGIN);
res.setHeader("access-control-allow-methods", "POST, GET, OPTIONS");
res.setHeader("access-control-allow-headers", req.headers["access-control-request-headers"] ?? "*");
if (req.method === "OPTIONS") return res.writeHead(204).end();
const path = new URL(req.url, "http://relay").pathname;
if (req.method === "GET" && path === "/health") return json(res, 200, { ok: true, relayPath: RELAY_PATH });
if (path !== RELAY_PATH) return error(res, new RelayError(404, "route-not-found", "Relay server route was not found.", { path }));
if (req.method !== "POST") return error(res, new RelayError(405, "relay-method-unsupported", "Relay endpoint only accepts POST.", { method: req.method }));
let ctx = {};
try {
const body = await readJson(req);
ctx = { requestId: body?.tfAgentRelay?.requestId, turnId: body?.tfAgentRelay?.turnId };
const { relay, transport } = validate(body);
const target = resolve(relay, transport);
const controller = new AbortController();
res.on("close", () => controller.abort());
const upstream = await fetch(target.url, {
method: "POST", signal: controller.signal,
headers: { "content-type": "application/json", ...target.headers },
body: JSON.stringify(target.body),
});
await proxy(upstream, res);
console.log(JSON.stringify({ event: "relay.completed", ...ctx, aiProvider: transport.aiProvider, model: target.body.model ?? transport.model, upstreamStatus: upstream.status }));
} catch (e) {
console.error(JSON.stringify({ event: "relay.failed", ...ctx, code: e.code ?? "relay-internal-error", message: e.message }));
if (!res.headersSent) error(res, e, ctx); else res.end();
}
}).listen(PORT, "127.0.0.1", () => console.log(`Relay: http://localhost:${PORT}${RELAY_PATH}`));
위에서 아래로 읽으면 릴레이 패턴 전체를 파악할 수 있습니다. VENDORS 객체가 정책을 정의하고, validate()가 스펙을 검사하며,
resolve()가 허용 목록을 적용해 키를 선택하고, proxy()가 응답을 그대로 반환합니다. 나머지는 일반적인 HTTP 처리입니다.
코드의 VENDORS는 릴레이 서버에 설정한 공급자 경로의 목록입니다. 스펙에서는 이 경로를 vendor라고 부르며, 같은 공급자
계열에 인증 정보나 허용 모델이 다른 경로를 여러 개 둘 때 구분하는 이름입니다. 경로가 공급자마다 하나뿐이라면 공급자와
같은 뜻으로 읽어도 됩니다.
서버 환경에 공급자 하나에 해당하는 변수를 설정하고 실행합니다. 허용 목록이 곧 정책입니다. 목록에 없는 모델을 요청하면 공급자를 호출하기 전에 거부됩니다. 아래 명령은 macOS와 Linux의 bash 셸을 기준으로 합니다.
export CLAUDE_API_KEY=sk-ant-your-test-key
export CLAUDE_ALLOWED_MODELS=claude-sonnet-5
node relay-server.mjs
Windows PowerShell에서는 같은 변수를 $env: 구문으로 설정합니다.
$env:CLAUDE_API_KEY = "sk-ant-your-test-key"
$env:CLAUDE_ALLOWED_MODELS = "claude-sonnet-5"
node relay-server.mjs
서버가 시작되면 콘솔에 릴레이 서버 주소가 출력됩니다.
Relay: http://localhost:8787/ai-agent/relay
아래 요청은 모델을 호출하지 않고 스펙 검증만 확인합니다.
curl -i http://localhost:8787/ai-agent/relay \n -H 'Content-Type: application/json' \n --data '{"tfAgentRelay":{},"tfAgentTransport":{}}'
릴레이 서버는 400과 relay-request-invalid로 응답합니다. 응답 본문의 JSON은 보기 쉽게 정렬한 것입니다.
HTTP/1.1 400 Bad Request
content-type: application/json; charset=utf-8
{
"error": {
"code": "relay-request-invalid",
"message": "Relay request body.tfAgentTransport.body must be a JSON object.",
"details": null
},
"relay": { "requestId": null, "turnId": null, "upstreamStatus": null }
}
chat-ui를 릴레이 서버로 전환
빠른 시작의 chat-ui 샘플에서 createMessage()만 아래 함수로 교체하고, 페이지에서 API 키와 모델 ID 입력란을
제거합니다. 샘플의 나머지 부분인 getTools(), tool loop, 검증, 승인, tool.execute()는 그대로 유지되며 예상 결과도 같습니다.
승인한 텍스트가 문서에 추가됩니다.
// Relay mode: the browser posts the envelope to your relay - no provider key in the page.
const RELAY_URL = "http://localhost:8787/ai-agent/relay"; // same-origin deployment: "/ai-agent/relay"
const MODEL = "claude-sonnet-5"; // must be in the relay's allowlist
const sessionId = crypto.randomUUID();
async function createMessage({ system, messages, tools }) {
const requestId = crypto.randomUUID();
const res = await fetch(RELAY_URL, {
method: "POST",
headers: { "content-type": "application/json" },
signal: AbortSignal.timeout(65000),
body: JSON.stringify({
tfAgentRelay: {
requestId, turnId: requestId, agentId: "chat-ui", sessionId,
aiProviderConfig: { connectionMode: "relay", aiProvider: "claude", model: MODEL },
},
tfAgentTransport: {
adapterId: "claude-messages", aiProvider: "claude", model: MODEL, stream: false,
body: { max_tokens: 8192, system, messages, tools }, // the same body direct mode sent to the provider
},
}),
});
if (!res.ok) {
const { error } = await res.json().catch(() => ({ error: {} }));
throw new Error(`Relay request failed (HTTP ${res.status}${error?.code ? `, ${error.code}` : ""}).`);
}
const data = await res.json();
return { content: data.content, stop_reason: data.stop_reason }; // same shape as direct mode
}
이 tfAgentRelay에는 릴레이 서버가 확인하는 항목만 담았습니다. 오류 응답과 로그에 그대로 포함되는 식별자, 그리고
tfAgentTransport와 대조하는 공급자·모델 쌍입니다. Playground는 대화 기록과 도구 카탈로그까지 포함한 전체 스냅샷을 보냅니다.
릴레이 서버가 공급자별 페이로드가 아닌 의미 정보를 기준으로 로그를 남기고 정책을 적용할 수 있도록 하기 위해서입니다. 두 형태
모두 유효하며, 각 필드는 릴레이 서버 요청/응답 스펙에 정리되어 있습니다.
프로덕션에서 추가할 항목
스켈레톤은 키를 브라우저 밖에 두고 모델 허용 목록이라는 정책 하나를 적용합니다. 공유 환경이나 프로덕션 배포에서 추가되는 항목도 모두 릴레이 서버의 책임입니다. 요청이 비용으로 이어지기 전에 거치는 지점이 릴레이 서버뿐이기 때문입니다.
- 호출자를 인증합니다.
createMessage()에서 애플리케이션의 세션 토큰을 요청 헤더에 담아 보내고, 릴레이 서버는 본문을 읽기 전에 토큰이 없는 요청을 거부합니다. CORS는 인증을 대체하지 않습니다. - 문서와 테넌트 권한을 확인합니다. 예약된 두 키와 같은 수준의 최상위 필드에 식별자를 담아 보내고, 로그인한 사용자와 대조합니다.
- 비용을 제한합니다. 테넌트별로 요청 크기·호출 빈도·토큰 한도를 두고, 요청된 모델을 그대로 신뢰하는 대신 서버에서 모델을 고정합니다.
- 민감 정보 없이 기록합니다.
requestId,turnId, 공급자, 모델, 상태 코드, 지연 시간을 기록합니다. 공급자 인증 헤더와 문서 전체 내용은 기록하지 않습니다. - HTTPS를 사용합니다. 애플리케이션과 릴레이 서버 모두에 적용하고, 운영 중인 origin만 허용합니다.
로컬에서 403과 cors-origin-not-allowed가 반환되면 페이지 주소가 RELAY_SERVER_CORS_ORIGIN과 일치하는지 확인합니다.
localhost와 127.0.0.1은 서로 다른 origin입니다. 403과 relay-model-not-allowed가 반환되면 허용 목록에 모델을 추가하거나
MODEL을 변경합니다. 502가 반환되면 오류 본문의 relay.upstreamStatus를 확인합니다. 401은 키 문제, 429는 공급자의 호출
빈도 제한입니다. 연결이 거부되면 릴레이 서버가 예상한 포트에서 실행 중인지 확인합니다.