# Genie Agent 외부 소비자 연동 가이드 (`/responses`)

> 대상: 외부 프론트엔드 앱(브라우저) + 자체 백엔드(BFF)를 가진 팀.
> 목표: 기존 `/api/chat`(내부 React용) SSE에 맞춰둔 매핑을 **`/responses` 표준 이벤트로 재매핑**해 빠르게 전환.
> 인증: **Azure Entra ID** 로그인 → **BFF가 Databricks 토큰으로 교환**해 보관·전달.
>
> **인증 방식 (BFF 패턴)**: BFF가 Entra 로그인 후 **Entra→Databricks 토큰 교환까지 수행** → **Databricks 토큰**을 `Authorization: Bearer`로 전달. 우리 백엔드는 무변경으로 **지금 동작**하며, 토큰 재사용·refresh는 BFF가 관리합니다(요청당 교환 없음).

---

## 1. 개요 / 왜 바뀌나

| | 기존 `/api/chat` (사용 금지) | **`/responses` (외부 계약)** |
|---|---|---|
| 위치 | Express 프론트 `:3100` | **Python 백엔드 `:8001/responses`** |
| 인증 | 세션/쿠키 | **`Authorization: Bearer`** |
| SSE 포맷 | Vercel AI SDK UI stream (`text-delta` 등) | **표준 ResponsesAgent** (`response.output_text.delta` 등) |
| 용도 | 내부 React 클라이언트 | **프로그래매틱/외부 소비자** |

`/api/chat`은 세션 인증 + AI SDK 버전 종속이라 외부 계약에 부적합합니다. **외부 앱은 `/responses`에 매핑**하세요.

## 2. 연동 아키텍처 (BFF가 인증 엣지 + 릴레이)

**외부 앱의 자체 백엔드(BFF)** 가 ① Entra 로그인(서버사이드) ② **Entra→Databricks 토큰 교환** ③ 토큰을 httpOnly 세션 쿠키에 보관·갱신 ④ 우리 `/responses` 중계까지 담당합니다. (우리 Genie 앱의 Express 방식과 **동일 패턴**)

```
[외부 브라우저] --httpOnly 세션 쿠키(토큰 아님)--> [외부 BFF] --server-to-server(Bearer Databricks 토큰)--> [Genie /responses]
                                                    ├ /auth/login, /auth/callback (Entra 로그인)
                                                    ├ Entra id_token → Databricks 토큰 교환(federation) → 암호화 세션 쿠키 저장
                                                    ├ 만료 임박 시 refresh(§3.5)
                                                    └ SSE pass-through 릴레이
        ↑____________________________ SSE ____________________________|
```

- **브라우저는 토큰을 갖지 않음** — httpOnly 암호화 세션 쿠키만 보유(JS가 못 읽음 → XSS 탈취 방지). 실제 Entra/Databricks 토큰은 **BFF에만**.
- 브라우저는 **BFF(same-origin)만** 호출 → **CORS 없음**.
- BFF → `POST http://<genie-backend>:8001/responses` (server-to-server). 우리 백엔드는 브라우저에 노출 안 됨.
- **네트워크**: BFF가 Genie 백엔드(`:8001`)에 도달 가능해야 함(사내망/방화벽).

## 3. 인증 — BFF가 Entra 로그인 + 세션 쿠키 (우리 방식 미러링)

### 3.1 Entra 앱 등록 (외부 앱용 **별도** 등록, Web/confidential)
- 신규 App registration, **Web 플랫폼**, Redirect URI = `https://<외부-BFF>/auth/callback`.
- **자체 client secret** 발급(외부 BFF가 서버사이드 code 교환에 사용 — 우리 secret 공유 안 함).
- API permissions(Delegated): `openid profile email offline_access`.
- → 외부 앱 `client_id = B` (우리 = `A`와 다름).

### 3.2 Databricks federation `aud` 정렬 (앱 2개 유지)
같은 테넌트면 **issuer 동일**, `aud`만 앱별로 다름. federation policy의 **audiences를 리스트로** 두 앱을 수용:
```
oidc_policy:
  issuer:    https://login.microsoftonline.com/<tenant-id>/v2.0
  audiences: [ <our-client-id-A>, <external-client-id-B> ]
  subject_claim: email
```
- BFF가 이 policy로 교환하므로 audiences에 외부 앱 `B`가 있으면 됩니다.
- 소비 앱이 계속 늘 예정이면 → 공유 API audience(`api://<shared>`) 방식도 대안.

### 3.3 BFF 로그인/세션 구현 (우리 Express 패턴과 동일)
BFF가 아래를 구현합니다 (우리 내부 구현 `entra-oauth.ts`/`routes/auth.ts`/`oauth-session.ts`와 동형):
- **`GET /auth/login`**: PKCE(verifier/challenge S256)+state 생성 → `pending_auth` 암호화 쿠키 저장 → Entra `authorize`로 리다이렉트(`prompt=none` 사일런트 우선, `login_required` 시 `prompt=login` 재시도).
- **`GET /auth/callback`**: state 검증 → **client secret으로 code 교환** → Entra 토큰(id_token, refresh_token) 획득 → **id_token을 Databricks 토큰으로 교환**([3.4](#34-entradatabricks-토큰-교환-bff에서-수행)) → **httpOnly 암호화 세션 쿠키에 저장**(`{databricks_access_token, entra_refresh_token, expires_at, email}`).
- **세션 미들웨어**: 미인증 시 **top-level 네비게이션만** `/auth/login`으로 302, **자산/xhr/api는 401**(favicon 등이 경쟁 로그인 유발 방지). 만료 임박 시 refresh([3.5](#35-토큰-갱신-refresh--필수)).
- **쿠키 속성**: `httpOnly` + `Secure`(https) + `SameSite=lax` + 암호화(자체 SESSION_SECRET). 브라우저는 토큰 원문 미보유.

### 3.4 Entra→Databricks 토큰 교환 (BFF에서 수행)
callback에서 받은 **Entra id_token**을 RFC 8693 token-exchange로 Databricks 토큰으로 교환합니다:
```
POST {DATABRICKS_HOST}/oidc/v1/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<Entra id_token>
&subject_token_type=urn:ietf:params:oauth:token-type:jwt
&scope=<필요 시, 예: sql genie model-serving>
```
- **client secret 불필요** — federation policy([3.2](#32-databricks-federation-aud-정렬-앱-2개-유지))가 iss/aud로 통제. 응답 `{access_token, expires_in}`의 `access_token`이 **사용자별 Databricks(OBO) 토큰**.
- 이 **Databricks 토큰을 세션에 저장**하고, `/responses` 호출 시 `Authorization: Bearer <databricks_access_token>`로 첨부.
- 우리 백엔드는 이 토큰을 **그대로 OBO 실행** — 추가 변환·검증 없음(**지금 동작**, 백엔드 무변경).

### 3.5 토큰 갱신 (refresh) — 필수
federation으로 받은 **Databricks 토큰은 자체 refresh_token이 없습니다.** 갱신은 **Entra refresh_token으로 새 id_token을 받아 재교환**합니다 (우리 `oauth-session.ts`와 동형):
```
만료 임박(expires_at - now < THRESHOLD) 시:
  Entra refresh_token
    --(POST Entra token, grant_type=refresh_token, scope="...offline_access")--> 새 id_token(+회전 refresh_token)
  새 id_token
    --(token-exchange, §3.4)--> 새 Databricks 토큰
  세션 갱신: {databricks_access_token, entra_refresh_token(회전 시 교체), expires_at = now + expires_in}
```
- **THRESHOLD는 백엔드 최대 요청 시간보다 크게** 잡으세요(예: `660s` > 요청 상한 ≈`570s`). 그래야 **장시간 스트리밍 요청 도중 토큰이 만료되지 않습니다**(요청 시작 시점에 이미 갱신됨).
- **단일 비행(single-flight)**: 같은 refresh_token에 대한 동시 갱신을 하나의 호출로 합치세요. 회전(rotating) refresh_token이 동시 요청에 의해 무효화되는 경합을 방지합니다.
- Entra refresh_token까지 만료/무효면 → **재로그인 유도**(`/auth/login`).

## 4. `/responses` 요청 규약

```
POST http://<genie-backend>:8001/responses
Authorization: Bearer <Databricks 토큰>   # BFF가 Entra→Databricks 교환(§3.4)으로 얻은 토큰
Content-Type: application/json

{
  "input": [
    { "role": "user", "content": "26년 5월 환율 영향도를 알려줘" }
  ],
  "stream": true,
  "context": { "conversation_id": "<대화 UUID>", "user_id": "<사용자 이메일>" },
  "custom_inputs": {
    "model_config": { "executor": "databricks-gpt-5-4-mini:medium", "chart": "databricks-claude-haiku-4-5" }
  }
}
```

| 필드 | 필수 | 설명 |
|------|:---:|------|
| `input[]` | ✅ | 메시지 배열. `content`는 문자열 또는 `[{type:"input_text",text}]`. |
| `stream` | ✅ | `true`(SSE) / `false`(단건 JSON). |
| `context.conversation_id` | 권장 | 대화 이어가기 식별자(UUID). |
| `context.user_id` | 권장 | 사용자 식별(이메일). |
| `custom_inputs.model_config` | 선택 | executor/chart 모델 선택. 생략 시 기본값. |

## 5. `/responses` 응답 규약 (SSE, `stream:true`)

- 형식: `data: {JSON}\n\n` 연속, 종료는 `data: [DONE]\n\n`.
- 주요 이벤트:

| `type` | 내용 |
|--------|------|
| `response.output_text.delta` | 증분 텍스트: `{ "item_id", "delta": "..." }` (진행 마커도 여기 포함 — [6절](#6-진행표시-마커-규약)) |
| `response.output_item.done` | **최종 메시지 + `custom_outputs`**: `{ "item": {message, content:[{output_text, text}]}, "custom_outputs": { chart_specs, references } }` |
| `{"trace_id":"..."}` | (선택) 트레이스 ID — 요청 시 트레이스 반환을 요청한 경우에만 |
| `data: [DONE]` | 스트림 종료 |

> ⚠️ **`response.completed` 이벤트는 없습니다.** `custom_outputs`(차트/데이터 refs)는 **`response.output_item.done`에 함께** 실려 옵니다.

### 실제 시퀀스 샘플
```
data: {"type":"response.output_text.delta","item_id":"msg_1","delta":"<!--status:🔍 질의 분석 중...-->"}

data: {"type":"response.output_text.delta","item_id":"msg_1","delta":"💡 분석 계획을 수립합니다\n"}

data: {"type":"response.output_text.delta","item_id":"msg_1","delta":"5월 환율은 ..."}

data: {"type":"response.output_item.done","item":{"type":"message","id":"msg_1","role":"assistant","content":[{"type":"output_text","text":"...(전체 텍스트)..."}]},"custom_outputs":{"chart_specs":{"chart_xxx":{...}},"references":{"ref_1":{...}}}}

data: {"trace_id":"tr-..."}          # (선택) 트레이스 반환 요청 시에만

data: [DONE]
```
> 완전판 예시: [responses-sse-mock-example.md](responses-sse-mock-example.md)

### 비스트리밍(`stream:false`) 응답
```json
{
  "output": [{ "type":"message", "content":[{ "type":"output_text", "text":"..." }] }],
  "custom_outputs": { "chart_specs": { "chart_xxx": { } }, "references": { "ref_1": { } } }
}
```

## 6. 진행표시 마커 규약

`response.output_text.delta`의 `delta` 텍스트 안에 **실제 답변과 섞여** 진행 마커가 옵니다:

| 마커 | 의미 | 형식 |
|------|------|------|
| `<!--status:텍스트-->` | 현재 진행 상태(스피너) | 텍스트 그대로 |
| `<!--step-status:{JSON}-->` | 구조화 스텝 | JSON: `{ id, label, status("running"/"done"/"error"), elapsed, detail, result }` |

**처리 방식(택1)**
- **파싱**: 마커를 추출해 진행 UI(스피너/스텝 카드)로 렌더. 나머지 텍스트가 답변 본문.
- **스트립**: 마커를 제거하고 본문만 표시.

**⚠️ 델타 경계 버퍼링 필수**: 마커가 여러 delta에 걸쳐 쪼개질 수 있음(`<!--sta` + `tus:...-->`). **누적 버퍼에 모아 완결된 마커만** 추출하세요. 청크 단위 즉석 매칭은 마커가 깨집니다.

`custom_outputs`(차트 spec, 데이터 refs)는 **`response.output_item.done` 이벤트**에 함께 실립니다(비스트리밍은 응답 최상위 `custom_outputs`). 별도 `response.completed` 이벤트는 없습니다.

## 7. 기존 SSE(`/api/chat`) ↔ `/responses` 재매핑 ⭐

기존에 `/api/chat`(AI SDK) 이벤트에 매핑해두셨다면, 아래 대응으로 바꾸세요.

| 기존 (`/api/chat`, AI SDK) | `/responses` (표준) | 매핑 |
|---|---|---|
| `{"type":"start","messageId"}` | (대응 이벤트 없음) | **추가 불필요** — 스트림 열릴 때/첫 delta에서 소비자가 메시지 시작 판단 |
| `{"type":"start-step"}` | (대응 이벤트 없음) | **추가 불필요** — 스텝 UI는 delta 안의 `<!--step-status:{JSON}-->` 마커로 대응 |
| `{"type":"text-start","id"}` | (대응 이벤트 없음) | **추가 불필요** — 첫 delta에서 텍스트 시작 판단 |
| **`{"type":"text-delta","delta"}`** | **`{"type":"response.output_text.delta","delta"}`** | **1:1 (핵심)** |
| `{"type":"text-end"}` | `response.output_item.done` | 텍스트 종료 (+ `custom_outputs`) |
| `{"type":"finish"}` / `[DONE]` | (선택 `{"trace_id"}` 선행) `[DONE]` | 완료 — **`response.completed` 없음** |

- **핵심**: 실제 텍스트는 `delta` 필드로 **1:1 대응**.
- **`start`/`start-step`/`text-start`는 AI SDK가 자기 렌더링 상태머신용으로 쓰는 생명주기 이벤트**입니다. `/responses`엔 대응 이벤트가 없고, **추가할 필요도 없습니다** — 소비자가 아래처럼 스스로 시작/종료를 판단하면 됩니다. (없는 이벤트를 합성하는 건 AI SDK 파서를 그대로 쓰는 경우만 필요하며, 재매핑 방식에선 불필요)
- **진행 마커는 양쪽 동일**(delta 안에 포함) → 파싱 로직 그대로 재사용 가능. AI SDK의 `start-step`(스텝 개념)은 우리 `<!--step-status:-->` 마커가 대신합니다.

### 소비자 상태머신 (start/text-start 없이 처리)
```
스트림 시작                     → 새 어시스턴트 메시지 버퍼 생성   (start / text-start 대체)
response.output_text.delta      → delta.text 버퍼에 append + 마커 파싱(status/step-status)
response.output_item.done       → item.content[].text = 최종 답변 완성본(진행 delta와 별개, 답변 본문은 여기서 한 번에)
                                  + custom_outputs(charts/refs) 처리
{"trace_id"}(선택) / [DONE]     → 메시지 종료
```

### 전환 체크리스트
- [ ] 호출 대상을 `:3100/api/chat` → **BFF 경유 `:8001/responses`** 로 변경.
- [ ] 인증: **브라우저↔BFF는 BFF 세션 쿠키**, **BFF↔Genie는 `Authorization: Bearer`(세션의 Databricks 토큰 — §3.4 교환 결과)**.
- [ ] 이벤트 파서를 `text-delta` → `response.output_text.delta`로 (delta 필드 동일).
- [ ] 완료 처리를 `finish` → `[DONE]`로 (`custom_outputs`는 `output_item.done`에서 수집; `response.completed` 없음).
- [ ] 진행 마커 파서(버퍼링) 유지.

## 8. BFF 구현 가이드

BFF의 역할: **(a) Entra 로그인**([3.3](#33-bff-로그인세션-구현-우리-express-패턴과-동일)) + **(b) Entra→Databricks 교환·저장·refresh**([3.4](#34-entradatabricks-토큰-교환-bff에서-수행)·[3.5](#35-토큰-갱신-refresh--필수)) + **(c) `/responses` SSE pass-through 릴레이**. 아래는 (c) 릴레이 — 세션에서 **Databricks 토큰**을 꺼내 첨부하고, SSE를 변형 없이 흘려보냅니다.

```js
// 외부 BFF (Node 예시) — 세션 미들웨어가 인증/refresh(§3.5)를 이미 처리한 뒤의 /chat
app.post('/chat', requireSession, async (req, res) => {
  const dbxToken = req.session.databricks_access_token;  // ← 교환(§3.4)·refresh(§3.5) 완료된 서버측 토큰
  const upstream = await fetch(`${GENIE_BACKEND}/responses`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${dbxToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ input: req.body.input, stream: true, context: {...} }),
  });
  if (!upstream.ok) return res.status(upstream.status).json({ error: 'upstream' });

  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('X-Accel-Buffering', 'no');   // nginx 등 버퍼링 방지
  upstream.body.pipe(res);                     // 스트림 그대로 릴레이(버퍼링 금지)
});
```

- 청크를 모으지 말고 즉시 흘려보낼 것. 앞단 프록시가 있으면 `proxy_buffering off`.
- SSE 타임아웃 넉넉히(장시간 질의).

## 9. 에러 / 엣지 케이스

| 상황 | 처리 |
|------|------|
| `401` | Databricks 토큰 무효/만료 → BFF가 재교환(§3.5) 또는 재로그인 유도. |
| 토큰 만료 임박 | BFF가 Entra refresh→재교환(§3.5), **단일 비행**으로 동시 갱신 합침. |
| refresh_token 무효 | Entra 재로그인(`/auth/login`). |
| 스트림 중단 | `[DONE]` 미수신 시 재시도/에러 표시. |
| 부분 마커 | 델타 버퍼링으로 방지([6절](#6-진행표시-마커-규약)). |
| 500 / `{"error":...}` 이벤트 | 사용자 오류 메시지 표시. |

## 10. 테스트 (CLI 토큰으로 검증)

BFF/Entra 없이도 **`/responses` 계약과 백엔드 동작을 지금 검증**할 수 있습니다. Databricks CLI로 로그인한 토큰이 **유효한 per-user Databricks(OBO) 토큰**이라, BFF가 federation 교환(§3.4)으로 얻는 토큰과 **동일하게 취급**됩니다(획득 경로만 다름).

**0) 사전 준비** — Databricks CLI 설치 (v0.2xx+):
```bash
# macOS/Linux
curl -fsSL https://raw.githubusercontent.com/databricks/setup-cli/main/install.sh | sh
databricks --version                         # 설치 확인
```

**1) 최초 로그인 (U2M OAuth)** — 한 번도 로그인한 적 없다면 프로파일부터 생성합니다.
`databricks auth token`은 **U2M(브라우저) 로그인 프로파일에서만** 동작하므로, `auth login`으로 프로파일을 만듭니다:
```bash
# --host = 워크스페이스 URL, --profile = 새로 만들 프로파일 이름(임의)
databricks auth login --host https://lge-ic360.cloud.databricks.com --profile lge-ic360
# → 브라우저가 열리며 Databricks 로그인 → 완료 시 ~/.databrickscfg에 프로파일 저장(auth type: databricks-cli)
```
확인:
```bash
databricks auth profiles                     # Name/Host/Valid=YES 표시되면 성공
```

**2) 토큰 획득** (위에서 만든 프로파일 사용):
```bash
TOKEN=$(databricks auth token -p lge-ic360 | jq -r .access_token)
```

**3) `/responses` 호출** (백엔드 기본 포트 `:8000`):
```bash
curl -N -X POST http://localhost:8000/responses \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"input":[{"role":"user","content":"2026년 5월 환율 영향을 통화별로 보여줘"}],"stream":true}'
```
→ `data: {"type":"response.output_text.delta",...}` 스트림 → `response.output_item.done`(+`custom_outputs`) → `data: [DONE]` 확인.

**4) (선택) 한글 디코드해서 보기** — 원문 SSE의 `\uXXXX`는 **정상 JSON 이스케이프**(소비자가 파싱하면 자동 한글). 터미널에서 눈으로 보려면 파싱:
```bash
curl -sN -X POST http://localhost:8000/responses \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"input":[{"role":"user","content":"안녕"}],"stream":true}' \
| sed -u -n 's/^data: //p' | grep --line-buffered -v '^\[DONE\]' \
| jq -j --unbuffered '.delta // .item.content[0].text // empty'
```

> **CLI 토큰 주의**: 본인(개발자) 권한으로 실행됨(OBO). 단명(~1h)이며 `databricks auth token`이 호출 시마다 자동 refresh해 최신 토큰을 출력 — 만료되면 2)의 `TOKEN=...`만 다시 실행(재로그인 불필요). **개발/테스트 전용**(커밋·공유 금지). 실제 연동에선 이 토큰 대신 BFF의 교환 토큰(§3.4)을 사용합니다.

## 11. 현재 상태 / 전제조건

| 항목 | 상태 |
|------|------|
| `/responses` 엔드포인트 + 표준 SSE + 진행 마커 | ✅ 동작 |
| BFF가 교환한 **Databricks 토큰** Bearer → OBO 실행 | ✅ **동작 (지금)** |

- BFF가 Entra→Databricks 교환(§3.4)·refresh(§3.5)를 담당하면 **우리 백엔드 무변경으로 지금 동작**합니다. 요청당 교환이 없어(세션 재사용) 성능·부하에 유리합니다.
- 테스트는 [10절](#10-테스트-cli-토큰으로-검증)의 CLI 토큰으로 BFF 없이 선검증 가능 — federation 교환 부분만 BFF 구현 시 추가 확인.

---

### 부록: 참고
- 이 계약(요청/SSE/마커)은 **고정**입니다 → 외부 앱은 지금 매핑 작업을 진행하면 됩니다.
- BFF는 토큰 획득(§3.4)·refresh(§3.5)만 책임지고, `/responses` 요청/응답 규약은 위 §4~§7 그대로입니다.
