<!-- task=m-seulgivibe-openai-compatible-provider/03+01,02_responses_passthrough plan=0 tag=SEULGI_RESPONSES -->

# Implementation Plan - SEULGI_RESPONSES

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

`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이 필수 완료 조건이다. 구현 후 검증을 실행하고, 실제 stdout/stderr를 붙이고, active 파일은 그대로 둔 뒤 review ready로 보고한다. selected Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 막는 경우에만 review stub의 `사용자 리뷰 요청`에 정확한 근거를 기록하고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 외부 secret/service 준비, 검증 증거 공백, 일반 범위 조정은 사용자 리뷰 요청이 아니라 후속 plan 또는 검증 기록으로 처리한다. finalization은 code-review-skill 전용이다.

## 배경

Codex의 Seulgivibe 설정은 `wire_api = "responses"`를 사용할 수 있어야 하지만, 현재 Edge `/v1/responses` handler는 provider-pool/OpenAI-compatible route를 명시적으로 거부한다. Chat Completions에는 raw provider tunnel passthrough가 있으므로 Responses도 같은 raw tunnel path를 제공해야 한다. 이 plan은 01의 config foundation과 02의 provider auth injection을 전제로 `/v1/responses` provider route를 raw passthrough로 여는 작업만 다룬다.

## 사용자 리뷰 요청 흐름

선택된 Milestone lock decision이 실구현을 차단할 때만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 직접 사용자에게 질문하지 않고, code-review가 그 요청을 검증해 `USER_REVIEW.md` 작성 여부를 결정한다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/routing-policy-model-orchestration/milestones/seulgivibe-openai-compatible-provider.md`
- Milestone link: [Milestone 문서](agent-roadmap/phase/routing-policy-model-orchestration/milestones/seulgivibe-openai-compatible-provider.md)
- Task ids:
  - `responses-passthrough`: OpenAI-compatible provider route에서 `/v1/responses` raw passthrough가 동작
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `agent-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-contract/outer/openai-compatible-api.md`
- `agent-spec/input/openai-compatible-surface.md`
- `packages/go/config/config.go`
- `apps/edge/internal/openai/routes.go`
- `apps/edge/internal/openai/chat_handler.go`
- `apps/edge/internal/openai/responses_handler.go`
- `apps/edge/internal/openai/stream.go`
- `apps/edge/internal/openai/types.go`
- `apps/edge/internal/openai/server_test.go`
- `apps/edge/internal/service/run_dispatch.go`
- `proto/iop/runtime.proto`
- `apps/node/internal/runtime/types.go`
- `apps/node/internal/adapters/openai_compat/openai_compat.go`

### SDD 기준

- SDD: [SDD.md](agent-roadmap/sdd/routing-policy-model-orchestration/seulgivibe-openai-compatible-provider/SDD.md)
- 상태: `[승인됨]`
- SDD 잠금: 해제
- 대상 Acceptance Scenario:
  - S04 -> `responses-passthrough`: Codex-style `/v1/responses` payload를 provider route에서 raw passthrough로 전달하고 model rewrite, streaming, provider auth를 검증한다.
- Evidence Map 반영: 이 plan의 Responses provider tunnel tests가 `responses-passthrough` Roadmap Completion evidence가 된다.

### 테스트 환경 규칙

- `test_env=local`.
- `agent-test/local/rules.md`와 `agent-test/local/edge-smoke.md`를 읽었다.
- 이 subtask는 Edge OpenAI Responses handler/test 변경이다. 적용 명령은 `go test ./apps/edge/internal/openai -count=1`이다.
- live Seulgivibe/Codex CLI smoke는 secret, 사내망, external service에 의존하므로 필수 검증이 아니다. 대신 provider tunnel fake service로 wire path를 검증한다.

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

- 기존 `TestResponsesProviderPoolDispatch`, `TestResponsesProviderPoolAppliesGenerationPolicy`, `TestResponsesProviderPoolThinkingPolicyOverridesStrictOutputDisable`는 provider-pool `/v1/responses`가 reject된다는 과거 behavior를 확인한다.
- provider `/v1/responses` raw body가 model만 rewrite하고 unknown fields/tools/max_output_tokens를 보존하는 테스트 없음.
- streaming Responses passthrough 여부를 검증하는 테스트 없음.
- provider auth header가 Responses tunnel에도 들어가는 테스트 없음.

### 심볼 참조

renamed/removed symbol 없음. 새 helper 후보: `decodeResponsesEnvelope`, `rewriteResponsesModel`, `tunnelResponsesPassthrough`.

### 분할 판단

shared task group은 `m-seulgivibe-openai-compatible-provider`다.

- predecessor `01_config_auth_catalog`: 현재 active predecessor이며 `complete.log` 없음.
- predecessor `02+01_provider_tunnel_auth`: 현재 active predecessor이며 `complete.log` 없음.
- 이 plan은 `03+01,02_responses_passthrough`로 01과 02 모두에 의존한다. 두 predecessor complete 전에는 구현하지 않는다.

### 범위 결정 근거

- Chat Completions passthrough semantics는 필요한 shared helper 호출 외에는 변경하지 않는다.
- normalized non-provider `/v1/responses` path의 기존 strict decode, prompt build, SubmitRun behavior는 유지한다.
- provider tunnel path는 raw provider passthrough로 정의한다: request body는 model rewrite와 provider auth header 외에는 보존한다.
- provider response body model echo rewrite/sideband injection은 하지 않는다. Responses passthrough는 provider-original bytes를 우선한다.
- Node/proto 변경은 하지 않는다.

### 빌드 등급

`cloud-G07`. external CLI/API compatibility와 raw passthrough semantics가 핵심이고, 기존 tests가 반대 behavior를 고정하고 있어 broad context가 필요하다.

## 의존 관계 및 구현 순서

이 plan의 디렉터리명 `03+01,02_responses_passthrough`는 predecessor index `01`, `02`를 요구한다. 구현 시작 전 아래 complete evidence가 필요하다.

- `01`: `agent-task/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/complete.log` 또는 matching archive complete.
- `02`: `agent-task/m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth/complete.log` 또는 matching archive complete.

둘 중 하나라도 없으면 구현하지 말고 review stub에 verification blocker로 기록한다.

## 구현 체크리스트

- [ ] [SEULGI_RESPONSES-1] `/v1/responses` handler를 provider route 판단 전 raw body 보존 구조로 바꾼다.
- [ ] [SEULGI_RESPONSES-2] provider route용 Responses raw tunnel submitter/model rewrite를 추가하고 provider auth header를 적용한다.
- [ ] [SEULGI_RESPONSES-3] 기존 reject tests를 passthrough tests로 갱신하고 raw body/stream/auth behavior를 검증한다.
- [ ] `go test ./apps/edge/internal/openai -count=1`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [SEULGI_RESPONSES-1] Route Before Strict Responses Normalization

#### 문제

`apps/edge/internal/openai/responses_handler.go:21-27`은 request body를 바로 strict decoder에 넣고, `29-36`에서 stream/background를 일괄 reject한다. `143-150`의 decoder는 허용된 필드 외 request를 reject한다. provider passthrough에서는 Codex/Responses payload의 unknown fields를 보존해야 하므로, provider route 판단 전에 strict normalized parsing을 강제하면 안 된다.

Before:

```go
// apps/edge/internal/openai/responses_handler.go:21
defer r.Body.Close()

var req responsesRequest
if err := decodeResponsesRequest(json.NewDecoder(r.Body), &req); err != nil {
    writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
    return
}

if req.Stream {
    writeError(w, http.StatusBadRequest, "invalid_request_error", "streaming is not supported for /v1/responses")
    return
}
```

#### 해결 방법

handler 시작에서 raw body를 읽고, route 판단에 필요한 envelope만 관대하게 decode한다. provider tunnel route면 strict `decodeResponsesRequest`와 prompt normalization을 건너뛴다. non-provider route는 기존 strict decoder와 stream/background reject를 유지한다.

After:

```go
rawBody, err := io.ReadAll(r.Body)
if err != nil {
    writeError(w, http.StatusBadRequest, "invalid_request_error", "invalid request body")
    return
}

env, err := decodeResponsesEnvelope(rawBody)
if err != nil {
    writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
    return
}
dispatch, ok := s.resolveRouteDispatch(env.Model)
// validate workspace from env.Metadata
if routeUsesProviderTunnel(dispatch) {
    s.tunnelResponsesPassthrough(w, r, env, dispatch, runMeta, rawBody, estimate, contextClass)
    return
}

var req responsesRequest
if err := decodeResponsesRequest(json.NewDecoder(bytes.NewReader(rawBody)), &req); err != nil {
    // existing normalized behavior
}
```

`decodeResponsesEnvelope`는 `model`, `metadata`, `stream`, `background`만 추출하고 unknown fields를 reject하지 않는다.

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

- [ ] `apps/edge/internal/openai/responses_handler.go`: raw body read, envelope decode, route split.
- [ ] `apps/edge/internal/openai/types.go` 또는 handler-local type: `responsesEnvelope` 추가.
- [ ] existing normalized path behavior가 유지되도록 기존 strict parsing을 non-provider branch에 남긴다.

#### 테스트 작성

- `TestResponsesProviderTunnelAllowsUnknownFields`: provider route에서 `tools`, `parallel_tool_calls`, `store` 같은 unknown field가 있어도 400이 아니고 tunnel body에 보존되는지 검증.
- 기존 normalized route unknown-field rejection test가 있다면 유지한다. 없으면 추가하지 않아도 된다.

#### 중간 검증

```bash
go test ./apps/edge/internal/openai -run 'TestResponsesProviderTunnelAllowsUnknownFields|TestResponsesProviderPoolDispatch' -count=1
```

### [SEULGI_RESPONSES-2] Responses Raw Tunnel Submitter

#### 문제

`apps/edge/internal/openai/responses_handler.go:65-68`은 provider tunnel route를 400으로 막는다. Chat path에는 `apps/edge/internal/openai/stream.go:311-321`와 `342-377`의 tunnel submitter가 있지만 Responses path에는 없다.

Before:

```go
// apps/edge/internal/openai/responses_handler.go:65
if routeUsesProviderTunnel(dispatch) {
    writeError(w, http.StatusBadRequest, "invalid_request_error", "/v1/responses is not supported for OpenAI-compatible provider model groups until raw passthrough parity is implemented")
    return
}
```

```go
// apps/edge/internal/openai/stream.go:356
tunnelReq := edgeservice.SubmitProviderTunnelRequest{
    Path:          "/v1/chat/completions",
    BuildBody: func(target string) ([]byte, error) {
        return rewriteChatCompletionModel(rawBody, target, req)
    },
```

#### 해결 방법

Responses 전용 submitter를 추가한다. request path는 `/v1/responses`, body는 `rewriteResponsesModel(rawBody, target)`만 적용한다. 02의 `providerTunnelAuthHeaders`를 호출해 provider auth를 동일하게 적용한다. response writer는 `writeProviderTunnelResponse(w, r, handle, env.Stream, "")`처럼 request model을 비워 model echo rewrite를 끈다.

After:

```go
func (s *Server) tunnelResponsesPassthrough(w http.ResponseWriter, r *http.Request, env responsesEnvelope, dispatch routeDispatch, runMeta map[string]string, rawBody []byte, estimate int, contextClass string) {
    headers, err := s.providerTunnelAuthHeaders(r)
    if err != nil {
        writeError(w, http.StatusBadRequest, "invalid_request_error", "provider auth header is required")
        return
    }
    tunnelReq := edgeservice.SubmitProviderTunnelRequest{
        NodeRef:       dispatch.NodeRef,
        ModelGroupKey: strings.TrimSpace(env.Model),
        Method:        http.MethodPost,
        Path:          "/v1/responses",
        Headers:       headers,
        BuildBody: func(target string) ([]byte, error) {
            return rewriteResponsesModel(rawBody, target)
        },
        Stream:       env.Stream,
        ProviderPool: dispatch.ProviderPool,
        // existing timeout/queue/metadata fields
    }
    handle, err := s.service.SubmitProviderTunnel(r.Context(), tunnelReq)
    // then writeProviderTunnelResponse(..., env.Stream, "")
}
```

`rewriteResponsesModel`은 JSON object의 `model` 필드만 target으로 바꾸고 나머지는 byte-level semantics에 가깝게 보존한다. invalid JSON은 `invalid JSON request`로 reject한다.

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

- [ ] `apps/edge/internal/openai/responses_handler.go`: tunnel branch 추가.
- [ ] `apps/edge/internal/openai/stream.go` 또는 new helper file: `rewriteResponsesModel`/submitter 배치.
- [ ] provider response model echo rewrite를 끄는지 확인.
- [ ] `runMeta`에는 token/header 값을 넣지 않는다.

#### 테스트 작성

- `TestResponsesProviderTunnelSubmitsResponsesPath`: path `/v1/responses`, method POST, no `SubmitRun`.
- `TestResponsesProviderTunnelRewritesOnlyModel`: caller model alias -> provider served model; `max_output_tokens`, `tools`, arbitrary fields 보존.
- `TestResponsesProviderTunnelForwardsProviderAuthHeader`: 02의 auth helper가 Responses tunnel에도 적용됨.
- `TestResponsesProviderTunnelStreaming`: `stream:true`일 때 tunnel request `Stream=true`이고 SSE body가 raw relay됨.

#### 중간 검증

```bash
go test ./apps/edge/internal/openai -run 'TestResponsesProviderTunnel(SubmitsResponsesPath|RewritesOnlyModel|ForwardsProviderAuthHeader|Streaming)' -count=1
```

### [SEULGI_RESPONSES-3] Replace Old Reject Expectations

#### 문제

`apps/edge/internal/openai/server_test.go:5566-5659`의 Responses provider-pool tests는 현재 reject behavior를 전제로 한다. 구현 후 이 테스트를 그대로 두면 새 behavior를 막는다.

#### 해결 방법

기존 test names를 유지하거나 새 names로 교체하되, 기대값을 provider tunnel passthrough로 바꾼다. Provider-pool generation policy는 normalized path의 `providerOptions()`로 변환하지 않는다. Raw Responses passthrough에서는 caller body의 `max_output_tokens`를 보존하고, 누락된 output-token 값에 `max_tokens`를 주입하지 않는다.

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

- [ ] `apps/edge/internal/openai/server_test.go`: old rejection assertions 제거/갱신.
- [ ] `SubmitRun` 미호출, `SubmitProviderTunnel` 호출을 명확히 assert.
- [ ] `staticProviderTunnelFrames` helper 재사용 또는 Responses-specific fixture 추가.

#### 테스트 작성

기존 세 테스트를 새 behavior에 맞게 갱신하고, SEULGI_RESPONSES-2의 새 테스트를 추가한다.

#### 중간 검증

```bash
go test ./apps/edge/internal/openai -run 'TestResponsesProviderPool|TestResponsesProviderTunnel' -count=1
```

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/edge/internal/openai/responses_handler.go` | SEULGI_RESPONSES-1, SEULGI_RESPONSES-2 |
| `apps/edge/internal/openai/stream.go` 또는 new same-package helper | SEULGI_RESPONSES-2 |
| `apps/edge/internal/openai/types.go` | SEULGI_RESPONSES-1, if envelope type is shared |
| `apps/edge/internal/openai/server_test.go` | SEULGI_RESPONSES-1, SEULGI_RESPONSES-2, SEULGI_RESPONSES-3 |

## 최종 검증

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

Expected: OpenAI handler tests pass with fresh execution. Go test cache output is not acceptable for this task; `-count=1` is required.

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