<!-- task=m-model-serving-load-routing/02+01_edge_reasoning_surface plan=0 tag=OE2E-EDGE -->

# Edge OpenAI Reasoning Surface Plan

## 이 파일을 읽는 구현 에이전트에게

구현 후 반드시 같은 디렉터리의 `CODE_REVIEW-*-G??.md`에서 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 검증 명령을 실행하고 실제 stdout/stderr를 남긴다. active 파일은 그대로 두고 리뷰 준비 상태를 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review 전용이다.

## 배경

Node가 Ollama thinking을 `reasoning_delta`로 보낸 뒤 Edge OpenAI-compatible API가 이를 content와 분리해 노출해야 한다. 현재 Edge는 `delta`만 SSE content로 쓰고 non-streaming collector도 content만 모은다. OpenWebUI류 UX를 맞추려면 streaming과 non-streaming 모두 `reasoning_content` 확장 필드를 제공해야 한다.

## 분석 결과

### 읽은 파일

- `apps/edge/internal/openai/server.go`
- `apps/edge/internal/openai/server_test.go`
- `apps/node/internal/adapters/ollama/ollama.go`
- `apps/node/internal/adapters/ollama/ollama_test.go`
- `apps/node/internal/runtime/types.go`
- `scripts/e2e-openai-ollama.sh`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-ops/roadmap/phase/serving-routing-optimization/milestones/model-serving-load-routing.md`

### 테스트 커버리지 공백

- Existing `TestChatCompletionsStreamsSSE` covers content SSE only.
- Existing non-streaming test covers content and usage only.
- No test covers `reasoning_delta` handling or `reasoning_content` response fields.

### 심볼 참조

- renamed/removed symbol: none.
- 새 local field 후보: `chatMessage.ReasoningContent`, `chatDelta.ReasoningContent`.

### 분할 판단

Split decision policy를 먼저 평가했다. 이 작업은 `m-model-serving-load-routing/01_node_reasoning_event` 완료 후 진행해야 한다. Node event 생성 없이 Edge response 변환을 독립 검증할 수는 있지만 실제 E2E 의미는 `01`의 `reasoning_delta` 관례에 의존한다. `03+01,02_e2e_field_validation`은 이 작업과 `01` 완료 후 진행한다.

### 범위 결정 근거

- Node Ollama adapter 변경은 제외한다. `01_node_reasoning_event`가 소유한다.
- fake Ollama smoke와 field binary 검증은 제외한다. `03+01,02_e2e_field_validation`이 소유한다.
- `/v1/responses` 구현은 범위 제외다. 마일스톤도 Responses API 세부 호환을 후속으로 분리했다.
- OpenAI 공식 schema 전체 호환은 범위 제외다. 이 작업은 `reasoning_content` extension passthrough만 다룬다.

### 빌드 등급

`cloud-G06` — 외부 OpenAI-compatible HTTP response schema와 streaming behavior를 바꾸며, Node event 관례에 의존한다.

## 구현 체크리스트

- [ ] `m-model-serving-load-routing/01_node_reasoning_event/complete.log`가 있거나 해당 변경이 현재 작업 브랜치에 반영되어 있는지 확인한다.
- [ ] non-streaming collector가 content와 reasoning을 분리 수집하도록 변경한다.
- [ ] streaming SSE가 `reasoning_delta`를 `delta.reasoning_content`로 내보내도록 변경한다.
- [ ] final non-streaming response가 `message.content`와 `message.reasoning_content`를 분리해 반환하도록 변경한다.
- [ ] Edge OpenAI unit tests에 non-streaming reasoning과 streaming reasoning assertions를 추가한다.
- [ ] 중간 및 최종 검증 명령을 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## 의존 관계 및 구현 순서

이 디렉터리 이름 `02+01_edge_reasoning_surface`는 같은 task group의 `01_node_reasoning_event`를 predecessor로 가진다. `agent-task/m-model-serving-load-routing/01_node_reasoning_event/complete.log`가 있거나 그 구현이 현재 브랜치에 통합된 상태에서 시작한다.

### [OE2E-EDGE-1] Non-Streaming Reasoning Response

#### 문제

[server.go](/config/workspace/iop/apps/edge/internal/openai/server.go:163)는 `collectRunText`에서 text만 받아 `message.content`에 넣는다. [server.go](/config/workspace/iop/apps/edge/internal/openai/server.go:269)의 collector는 `"delta"`만 모으고 `"reasoning_delta"`를 모으지 않는다.

Before:

```go
163 func (s *Server) completeChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, handle *edgeservice.RunHandle) {
164     text, usage, err := collectRunText(r.Context(), handle)
...
177             Message:      chatMessage{Role: "assistant", Content: text},
```

Before:

```go
288 switch event.GetType() {
289 case "delta":
290     b.WriteString(event.GetDelta())
291 case "complete":
```

#### 해결 방법

`collectRunText`를 `collectRunResult` 형태로 바꾸거나 return value를 확장해 `Content`, `ReasoningContent`, `Usage`를 반환한다. `"reasoning_delta"`는 reasoning builder에 쓰고 `"delta"`는 content builder에 쓴다. `chatMessage`에 `ReasoningContent string 'json:"reasoning_content,omitempty"'` field를 추가하고 response message에 채운다.

#### 수정 파일 및 체크리스트

- [ ] `apps/edge/internal/openai/server.go` collector return shape 변경
- [ ] `apps/edge/internal/openai/server.go` `chatMessage` response extension field 추가
- [ ] `apps/edge/internal/openai/server_test.go` non-streaming reasoning test 추가

#### 테스트 작성

추가: `TestChatCompletionsReturnsReasoningContentSeparately`

- fake events: `reasoning_delta:"thinking "`, `reasoning_delta:"more"`, `delta:"answer"`, `complete`
- assert: JSON `choices[0].message.reasoning_content == "thinking more"` and `content == "answer"`.

#### 중간 검증

```bash
go test -count=1 ./apps/edge/internal/openai
```

예상: pass.

### [OE2E-EDGE-2] Streaming Reasoning SSE

#### 문제

[server.go](/config/workspace/iop/apps/edge/internal/openai/server.go:221)는 stream loop에서 `"delta"`만 content chunk로 쓴다. [server.go](/config/workspace/iop/apps/edge/internal/openai/server.go:475)의 `chatDelta`는 `content` field만 가진다.

Before:

```go
221 switch event.GetType() {
222 case "delta":
223     if event.GetDelta() == "" {
224         continue
225     }
...
233         Delta: chatDelta{Content: event.GetDelta()},
```

Before:

```go
475 type chatDelta struct {
476     Role    string `json:"role,omitempty"`
477     Content string `json:"content,omitempty"`
478 }
```

#### 해결 방법

`chatDelta`에 `ReasoningContent string 'json:"reasoning_content,omitempty"'`를 추가한다. stream loop에 `"reasoning_delta"` case를 추가해 non-empty delta를 `chatDelta{ReasoningContent: event.GetDelta()}`로 SSE write한다. 기존 role prelude와 final `[DONE]` behavior는 유지한다.

#### 수정 파일 및 체크리스트

- [ ] `apps/edge/internal/openai/server.go` stream loop에 reasoning case 추가
- [ ] `apps/edge/internal/openai/server.go` `chatDelta` extension field 추가
- [ ] `apps/edge/internal/openai/server_test.go` streaming reasoning assertion 추가

#### 테스트 작성

갱신 또는 추가: `TestChatCompletionsStreamsReasoningSSE`

- fake events: `reasoning_delta:"think"`, `delta:"hi"`, `complete`
- assert body contains `"reasoning_content":"think"`, `"content":"hi"`, and `data: [DONE]`.

#### 중간 검증

```bash
go test -count=1 ./apps/edge/internal/openai
```

예상: pass.

## 수정 파일 요약

| 파일 | 항목 |
|---|---|
| `apps/edge/internal/openai/server.go` | OE2E-EDGE-1, OE2E-EDGE-2 |
| `apps/edge/internal/openai/server_test.go` | OE2E-EDGE-1, OE2E-EDGE-2 |

## 최종 검증

```bash
go test -count=1 ./apps/edge/internal/openai
```

예상: pass. Go test cache output은 허용하지 않는다.

모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
