릴레이 서버 요청/응답 스펙
이 페이지는 Editor AI SDK의 tool loop를 실행하는 호스트 애플리케이션과 릴레이 서버 사이의 스펙을 정의합니다. 직접 createMessage()를
구현한다면 요청 부분을, 직접 릴레이 서버를 구현한다면 응답과 오류 부분을 참고하십시오. Playground와 Thinkfree Relay Server가
이 스펙을 따르며, 릴레이 서버 가이드에서 서버 측을 구현한 Node.js 스켈레톤을 제공합니다.
엔드포인트
| 메서드와 경로 | 목적 | 성공 응답 |
|---|---|---|
POST /ai-agent/relay | 모델 요청 하나를 공급자에게 전달 | 공급자 응답 그대로. JSON, 또는 stream이 true이면 SSE |
OPTIONS /ai-agent/relay | CORS preflight | 204와 허용 헤더 |
GET /health | liveness 확인 | {"ok":true,"relayPath":"/ai-agent/relay"} |
POST /ai-agent/relay/files/upload | 첨부파일을 공급자의 파일 저장소에 업로드1 | {"uri":"…","name":"…"} |
POST /ai-agent/relay/files/delete | 공급자 파일 저장소에서 파일 삭제 | 공급자 응답, 또는 {"success":true} |
1. 파일 저장소를 제공하는 공급자에서만 동작합니다. 지원 범위는 파일 helper에서 설명합니다.
릴레이 경로는 배포 환경에 따라 정할 수 있습니다. /ai-agent/relay는 Thinkfree Relay Server가 사용하는 경로이며, Playground는
이 값을 하드코딩하지 않고 설정에서 읽습니다. 릴레이 네임스페이스 안의 다른 경로는 route-not-found(404)와 함께
오류 본문을 반환하고, 릴레이 경로에 POST 외의 메서드로 요청하면 relay-method-unsupported(405)를 반환합니다.
요청 본문
POST /ai-agent/relay, Content-Type: application/json입니다. 본문은 예약된 키 두 개를 가진 하나의 JSON 객체입니다. 그 밖의
최상위 키, 예를 들어 릴레이 서버가 확인하는 테넌트 식별자는 자유롭게 추가할 수 있으며, 예약된 두 이름과 겹치지 않아야 합니다.
{
"tfAgentRelay": { "…": "what this turn means" },
"tfAgentTransport": { "…": "which HTTP request to execute" }
}
스트리밍하지 않는 Claude 요청의 전체 예시입니다. Playground가 도구 하나를 사용하는 loop에서 보내는 형태입니다.
{
"tfAgentRelay": {
"requestId": "8c1d0f0e-2f7a-4d63-9b1e-3a5f6c7d8e90",
"turnId": "8c1d0f0e-2f7a-4d63-9b1e-3a5f6c7d8e90",
"runtimeId": "runtime-1",
"agentId": "office-agent",
"sessionId": "b1a2c3d4-0000-4000-8000-000000000001",
"aiProviderConfig": { "connectionMode": "relay", "aiProvider": "claude", "model": "claude-sonnet-5", "maxToolIterations": 8 },
"systemPrompt": "You edit the open Word document with the provided tools.",
"transcript": [
{ "role": "user", "content": "Append a short greeting.", "messageContent": { "parts": [{ "type": "text", "text": "Append a short greeting." }] } }
],
"input": { "parts": [{ "type": "text", "text": "Append a short greeting." }] },
"tools": [
{ "name": "insert_text", "description": "Append text to the document.", "inputSchema": { "type": "object", "properties": { "text": { "type": "string" } }, "required": ["text"] } }
],
"context": {
"runtime": null,
"agent": null,
"session": { "sessionId": "b1a2c3d4-0000-4000-8000-000000000001", "sessionName": null, "sessionDisplayName": null, "properties": {}, "aiProviderConfig": { "connectionMode": "relay", "aiProvider": "claude", "model": "claude-sonnet-5" } },
"turn": { "requestId": "8c1d0f0e-2f7a-4d63-9b1e-3a5f6c7d8e90", "turnId": "8c1d0f0e-2f7a-4d63-9b1e-3a5f6c7d8e90", "turnName": null, "turnDisplayName": null, "properties": { "documentId": "doc-42" } }
}
},
"tfAgentTransport": {
"adapterId": "claude-messages",
"aiProvider": "claude",
"model": "claude-sonnet-5",
"stream": false,
"body": {
"max_tokens": 8192,
"system": "You edit the open Word document with the provided tools.",
"messages": [{ "role": "user", "content": "Append a short greeting." }],
"tools": [{ "name": "insert_text", "description": "Append text to the document.", "input_schema": { "type": "object", "properties": { "text": { "type": "string" } }, "required": ["text"] } }]
}
}
}
tfAgentRelay: 이 turn의 의미
tfAgentRelay는 호스트 애플리케이션이 공급자 중립적인 형태로 만든 turn의 스냅샷입니다. 릴레이 서버는 이 정보를 로그 기록과, 공급자별
페이로드 형식에 의존하지 않아야 하는 정책 적용에 사용하며, 그중 일부만 tfAgentTransport와 대조합니다. 검사 열은 릴레이
서버가 확인하는 항목을 나타내며, 나머지 항목은 릴레이 서버가 자체 용도로 사용하도록 전달됩니다.
| 필드 | 타입 | 검사 | 의미 |
|---|---|---|---|
requestId | string | 오류 본문과 로그에 포함 | 모델 요청 하나를 처음부터 끝까지 식별 |
turnId | string | 오류 본문과 로그에 포함 | 사용자 turn을 식별. 한 tool loop에 속한 여러 요청에서 같은 값 |
runtimeId | string 또는 null | 없음 | 애플리케이션에 runtime 인스턴스가 있을 때 그 식별자 |
agentId | string | 없음 | 대화를 소유하는 agent 설정의 식별자 |
sessionId | string | 없음 | 대화의 식별자. 문서 하나, 탭 하나, 작업 하나에 대응 |
aiProviderConfig | object | aiProvider와 model이 tfAgentTransport의 값과 일치해야 함 | 호스트 애플리케이션이 사용 중인 것으로 인식하는 공급자 설정 |
relayVendor | string 또는 null | 둘 다 있으면 tfAgentTransport.vendor와 일치해야 함 | 릴레이 서버가 여러 vendor를 담당할 때 애플리케이션이 선택한 vendor. vendor는 릴레이 서버에 설정된 공급자 경로의 이름 |
systemPrompt | string | 없음 | 이 turn에 적용되는 시스템 프롬프트 |
transcript | transcript 메시지 배열 | 없음 | 이 turn 이전의 대화 기록 |
input | 메시지 콘텐츠 | 없음 | 현재 사용자 입력 |
tools | 도구 기술자 배열 | 없음 | 모델에게 제공한 도구 카탈로그 |
context | object | 형태만 검사. 각 범위는 null 또는 객체, properties도 객체 | runtime, agent, session, turn의 스냅샷 |
aiProviderConfig에는 connectionMode("relay"), aiProvider, model이 포함되며, 선택적으로 openAiCompatible(preset,
OpenAI 호환 경로에서 사용), maxToolIterations, auth가 포함됩니다. relay 모드에서 auth는 존재하지 않거나 null입니다.
호스트 애플리케이션에는 전송할 공급자 인증 정보가 없기 때문입니다.
context에는 runtime, agent, session, turn 네 범위가 있습니다. 각 범위는 null이거나, 범위의 식별자와 선택적인
표시 이름, 그리고 애플리케이션이 자체 값(예: 문서나 테넌트 식별자)을 채우는 properties 객체를 가진 객체입니다. 이 위치는
릴레이 서버에 권한 확인 데이터를 전달하기에 적합합니다. 릴레이 서버는 properties의 형태만 검사하고, 공급자에게는 전달하지
않기 때문입니다.
tfAgentRelay 안의 세 가지 콘텐츠 형태는 다음과 같습니다.
| 형태 | 필드 |
|---|---|
| Transcript 메시지 | role("user", "assistant", "tool"), content(string), 선택적으로 messageContent, requestId, turnId, meta, timestamp |
| 메시지 콘텐츠 | parts: { "type": "text", "text" }, { "type": "image", "source", "mediaType", "fileName" }, { "type": "file", … }의 배열 |
| 도구 기술자 | name, description, inputSchema(JSON Schema), 선택적으로 readOnlyHint, destructiveHint 같은 annotations |
tfAgentTransport: 실행할 요청
tfAgentTransport는 릴레이 서버가 실행하는 요청입니다. body는 호스트 애플리케이션이 direct 모드에서 공급자 API를 호출할 때 전송하는
요청 본문과 같으므로 릴레이 서버는 어떤 변환도 수행하지 않습니다. 인증 정보와 최종 모델을 덧붙여 전달할 뿐입니다.
| 필드 | 타입 | 필수 | 의미 |
|---|---|---|---|
adapterId | string | 예 | body를 생성한 공급자 어댑터. 아래 표 참고 |
aiProvider | string | 예 | 공급자 계열: claude, google, openai, openai-compatible |
model | string | 항상 전송 | 요청한 모델. 릴레이 서버는 운영자가 고정한 모델로 대체할 수 있으며, 일부 배포 환경은 모델이 없는 요청을 거부함 |
vendor | string | 릴레이 서버가 하나의 aiProvider에 여러 vendor를 두었을 때만 | 릴레이 서버에 설정된 공급자 경로(vendor)의 이름. 같은 aiProvider에 인증 정보나 허용 모델이 다른 경로를 여러 개 둘 때 구분함. null 대신 키 자체를 생략 |
stream | boolean | 예 | true이면 공급자의 SSE 스트림, false이면 JSON 본문 하나 |
pathHint | string | 아니오 | 설정된 endpoint 뒤에 붙는 경로. OpenAI 호환 경로에서만 사용 |
query | 문자열 object | 아니오 | 공급자 URL에 추가하는 query 매개변수. OpenAI 호환 경로에서만 사용 |
safeHeaders | 문자열 object | 아니오 | 호스트 애플리케이션이 전달해도 안전하다고 판단한 헤더. 인증 정보 등 릴레이 서버 소유 헤더가 항상 우선 |
body | object | 예 | 공급자에게 바로 전달할 수 있는 요청 본문 |
adapterId는 호스트 애플리케이션이 어떤 공급자 어댑터로 body를 만들었는지 나타내며, aiProvider와 함께 릴레이 서버가 호출할 공급자
endpoint를 결정합니다. 현재 사용하는 조합은 다음과 같습니다.
adapterId | aiProvider | 릴레이 서버가 호출하는 공급자 endpoint |
|---|---|---|
claude-messages | claude | Messages API |
google-gemini-3, google-gemini-2 | google | generateContent, stream이 true이면 streamGenerateContent?alt=sse |
openai-chat-completions | openai | Chat Completions |
openai-compatible-chat-completions | openai-compatible | 설정된 endpoint에 pathHint를 붙인 경로. 기본값 chat/completions |
공급자별 body를 tool loop에 필요한 필드만 남겨 보면 다음과 같습니다.
{ "max_tokens": 8192, "system": "…", "messages": [{ "role": "user", "content": "…" }], "tools": [{ "name": "insert_text", "description": "…", "input_schema": { "type": "object" } }] }
{ "systemInstruction": { "parts": [{ "text": "…" }] }, "contents": [{ "role": "user", "parts": [{ "text": "…" }] }], "tools": [{ "functionDeclarations": [{ "name": "insert_text", "description": "…", "parameters": { "type": "object" } }] }] }
{ "messages": [{ "role": "system", "content": "…" }, { "role": "user", "content": "…" }], "tools": [{ "type": "function", "function": { "name": "insert_text", "description": "…", "parameters": { "type": "object" } } }], "stream": false }
릴레이 서버는 Claude, OpenAI, OpenAI 호환 경로에서는 model을 본문에 추가하고, Gemini에서는 URL에 포함합니다. 인증 정보는
x-api-key(Claude), Authorization: Bearer(OpenAI와 OpenAI 호환의 기본값), 또는 key query 매개변수(Gemini)로 추가합니다.
대화 상태는 추가하지 않습니다. 본문에 이미 대화 전체가 포함되어 있기 때문입니다.
검증과 정책
릴레이 서버는 아래 순서로 요청을 검사하고 첫 번째 실패 지점에서 중단합니다. 모든 검사를 통과하기 전에는 공급자에게 어떤 요청도 보내지 않습니다.
| 순서 | 검사 | 오류 코드 | 상태 |
|---|---|---|---|
| 1 | 요청 Origin이 허용된 값인가 | cors-origin-not-allowed | 403 |
| 2 | 본문이 존재하고, 크기 제한 이내이며, 유효한 JSON인가 | relay-request-emptyrelay-request-too-largerelay-request-json-invalid | 400413400 |
| 3 | 본문, tfAgentRelay, tfAgentTransport, tfAgentTransport.body가 JSON 객체이고 context 범위의 형태가 올바른가 | relay-request-invalid | 400 |
| 4 | adapterId, aiProvider, stream이 올바른 타입으로 존재하고, model과 vendor가 있다면 문자열인가 | relay-transport-adapter-missingrelay-transport-provider-missingrelay-transport-model-missingrelay-transport-vendor-invalidrelay-transport-stream-invalid | 400 |
| 5 | tfAgentRelay.aiProviderConfig와 tfAgentTransport가 같은 공급자·모델·vendor를 가리키는가 | relay-provider-mismatchrelay-model-mismatchrelay-vendor-mismatch | 400 |
| 6 | vendor 경로가 존재하고 aiProvider와 일치하는가 | relay-vendor-not-allowedrelay-provider-not-allowedrelay-vendor-requiredrelay-vendor-provider-mismatchrelay-provider-unsupported | 403403400400400 |
| 7 | 모델이 경로의 허용 목록에 있는가 | relay-model-not-allowed | 403 |
| 8 | 경로에 인증 정보와 사용 가능한 설정이 있는가 | provider-config-missingprovider-config-invalidrelay-config-unavailable | 500500503 |
정책 결정은 6번과 7번 검사에서 이루어집니다. 경로는 tfAgentTransport.vendor에 지정된 vendor, 없으면 tfAgentRelay.relayVendor,
둘 다 없으면 aiProvider가 일치하는 유일한 설정 경로입니다. 모델은 경로가 허용하는 경우 요청한 모델이고, 요청에 모델이 없으면
경로의 기본 모델입니다. 배포 환경에 따라 서버에서 모델을 고정할 수도 있습니다. 이 경우에도 요청은 모델을 명시해야 하며, 응답은
고정된 모델에서 생성됩니다.
응답
성공
릴레이 서버는 200으로 응답하고 공급자 응답을 변경하지 않고 전달하므로, 호스트 애플리케이션이 받는 것은 공급자 고유의 형식입니다. JSON이면
Content-Type도 공급자의 값입니다. 스트림이면 text/event-stream; charset=utf-8과 Cache-Control: no-cache, no-transform이며,
각 SSE 이벤트는 공급자가 전송한 그대로 도착합니다.
| 공급자 | tool loop가 읽는 위치 |
|---|---|
| Claude | type이 text 또는 tool_use인 content[] 블록. 모델이 도구 실행을 요청하는 동안 stop_reason은 tool_use |
| Gemini | text 또는 functionCall을 가진 candidates[0].content.parts[], 그리고 candidates[0].finishReason |
| OpenAI와 OpenAI 호환 | choices[0].message.content와 choices[0].message.tool_calls[], 그리고 choices[0].finish_reason |
어떤 변환도 일어나지 않으므로 direct 모드에서 relay 모드로 전환한 호스트 애플리케이션은 응답 처리 코드를 그대로 유지합니다. 요청 URL과 요청 본문의 구조만 바뀝니다.
오류 본문
릴레이 서버가 요청을 거부하거나 처리에 실패하면 릴레이 서버 고유의 JSON 본문으로 응답합니다. 공급자의 오류 본문은 전달하지
않습니다. 릴레이 서버가 내용을 요약하고, 공급자의 상태 코드를 relay.upstreamStatus에 기록합니다.
{
"error": {
"code": "relay-model-not-allowed",
"message": "Relay model \"claude-opus-5\" is not allowed.",
"details": { "model": "claude-opus-5" }
},
"relay": {
"requestId": "8c1d0f0e-2f7a-4d63-9b1e-3a5f6c7d8e90",
"turnId": "8c1d0f0e-2f7a-4d63-9b1e-3a5f6c7d8e90",
"upstreamStatus": null
}
}
| 필드 | 의미 |
|---|---|
error.code | 이 페이지의 표에 정의된 안정적인 기계 판독용 코드 |
error.message | 사람이 읽는 설명. 최종 사용자가 아닌 개발자에게 표시하는 값 |
error.details | null, 또는 문제가 된 값을 담은 객체. 예: { "model" }, { "path" }, { "method" }, { "upstreamStatus" } |
relay.requestId, relay.turnId | 본문을 읽을 수 있었다면 tfAgentRelay에서 복사한 값, 그렇지 않으면 null |
relay.upstreamStatus | 실패 원인이 공급자에 있다면 공급자의 HTTP 상태 코드, 그렇지 않으면 null |
검증을 통과한 뒤 호스트 애플리케이션이 받을 수 있는 코드는 다음과 같습니다.
| 코드 | 상태 | 시점 |
|---|---|---|
upstream-request-failed | 502 | 공급자가 실패 상태로 응답. relay.upstreamStatus를 확인합니다. 401은 키 문제, 429는 공급자의 호출 빈도 제한 |
relay-config-unavailable | 503 | 릴레이 서버는 실행 중이지만 사용 가능한 공급자 설정이 없음 |
relay-internal-error | 500 | 릴레이 서버 내부의 예기치 않은 실패 |
route-not-found | 404 | 릴레이 네임스페이스 안의 알 수 없는 경로. details.path에 요청 경로 포함 |
relay-method-unsupported | 405 | 릴레이 경로에 POST 외의 메서드로 요청. details.method에 해당 메서드 포함 |
첫 번째 조각 이후에 실패한 스트리밍 응답은 상태 줄이 이미 전송되었으므로 이 오류 본문으로 대체할 수 없습니다. 릴레이 서버는 스트림을 종료하며, 호스트 애플리케이션은 불완전한 스트림을 실패한 요청으로 처리하고 재시도 전에 문서 상태를 확인해야 합니다.
CORS
릴레이 서버는 정확히 하나의 origin 또는 *를 허용합니다. 특정 origin을 설정하면 Origin 헤더가 다른 요청은 preflight를 포함해
라우팅 전에 cors-origin-not-allowed로 거부되며, 응답에 Access-Control-Allow-Credentials: true가 포함됩니다. *를 설정하면
모든 origin이 허용되고 credentials 헤더는 전송되지 않습니다. 서버 간 호출이나 curl처럼 Origin 헤더가 없는 요청은 CORS 대상이
아니므로 정상 처리됩니다. preflight 응답은 204이며 Access-Control-Allow-Methods: POST, GET, OPTIONS와, 요청된 헤더를
그대로 반환하는 Access-Control-Allow-Headers를 포함합니다.
파일 helper
파일 저장소를 제공하는 공급자의 첨부파일은 공급자 키가 서버에 남도록 릴레이 서버를 통해 업로드합니다. 두 helper 모두 선택적인
tfAgentRelay 객체를 받아 그 식별자를 오류 응답과 로그에 포함하며, 메인 엔드포인트와 같은 오류 본문으로 응답합니다.
| 엔드포인트 | 요청 필드 | 성공 응답 |
|---|---|---|
POST /ai-agent/relay/files/upload | mimeType, base64Body | Gemini Files API의 { "uri", "name" } |
POST /ai-agent/relay/files/delete | provider("google" 또는 "openai"), 그리고 Google은 fileUri, OpenAI는 fileId | 공급자의 삭제 응답, 또는 { "success": true } |
capability 조회
릴레이 서버는 허용하는 vendor와 모델을 공개하여 설정 UI가 그 선택지만 제시하도록 할 수 있습니다. 참고 구현은
GET /ai-agent/relay/capabilities에서 이 정보를 제공하지만, Thinkfree Relay Server는 제공하지 않으며 route-not-found로
응답합니다. 선택 기능으로 취급하고, 요청 자체가 이 기능에 의존하지 않도록 구현하십시오.
{
"protocolVersion": "1",
"relayPath": "/ai-agent/relay",
"capabilitiesPath": "/ai-agent/relay/capabilities",
"defaultVendor": "google",
"vendors": [
{ "vendor": "google", "displayName": "Google Gemini", "aiProvider": "google", "models": ["gemini-3.8-flash"], "defaultModel": "gemini-3.8-flash" }
]
}
응답에는 vendor, 표시 이름, 각 vendor가 대응하는 aiProvider, 허용 모델, 기본 모델이 포함됩니다. 키, 공급자 endpoint,
헤더·query 재정의 값은 포함되지 않습니다.