<!-- task=m-seulgivibe-openai-compatible-provider/02+01_provider_tunnel_auth plan=0 tag=SEULGI_TUNNEL -->

# Implementation Plan - SEULGI_TUNNEL

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

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

## 배경

Seulgivibe provider token은 사용자마다 다르고 raw text로 전달되어야 하므로 host-local `~/.claude/anthropic_key.sh`나 env secret을 IOP runtime이 읽으면 안 된다. 현재 raw provider tunnel에는 per-request headers가 이미 있으며, Node openai_compat adapter는 tunnel header를 provider request에 적용한다. 이 plan은 Edge OpenAI surface에서 `openai.provider_auth` 설정에 따라 inbound header를 provider tunnel header로 변환하는 작업만 다룬다.

## 사용자 리뷰 요청 흐름

선택된 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:
  - `provider-token-tunnel`: Edge OpenAI Chat Completions provider tunnel이 request-time raw user token을 provider Authorization header로 전달
- 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/stream.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`
- `apps/node/internal/adapters/openai_compat/openai_compat_test.go`

### SDD 기준

- SDD: [SDD.md](agent-roadmap/sdd/routing-policy-model-orchestration/seulgivibe-openai-compatible-provider/SDD.md)
- 상태: `[승인됨]`
- SDD 잠금: 해제
- 대상 Acceptance Scenario:
  - S03 -> `provider-token-tunnel`: configured provider auth request header의 raw user token을 provider `Authorization` header로 전달하고 missing-required는 dispatch 전에 차단한다.
- Evidence Map 반영: 이 plan의 Edge OpenAI handler auth forwarding/missing tests가 `provider-token-tunnel` Roadmap Completion evidence가 된다.

### 테스트 환경 규칙

- `test_env=local`.
- `agent-test/local/rules.md`와 `agent-test/local/edge-smoke.md`를 읽었다.
- 이 subtask는 Edge OpenAI handler/test 변경이다. 적용 명령은 `go test ./apps/edge/internal/openai -count=1`이다.
- Node/proto는 수정하지 않는다. 기존 Node tunnel header behavior는 `apps/node/internal/adapters/openai_compat/openai_compat_test.go`의 tunnel tests로 이미 존재하므로 이 subtask의 필수 명령에는 포함하지 않는다.

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

- Edge가 inbound provider auth header를 `SubmitProviderTunnelRequest.Headers.Authorization`으로 넣는 테스트 없음.
- provider auth required인데 header가 없을 때 provider tunnel을 열지 않는 테스트 없음.
- configured IOP inbound `Authorization`과 provider token header가 분리되는 테스트 없음.

### 심볼 참조

renamed/removed symbol 없음. 01에서 추가된 `EdgeOpenAIProviderAuthConf`/`ProviderAuth`를 읽는 helper를 새로 추가한다.

### 분할 판단

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

- predecessor `01_config_auth_catalog`: 현재 active predecessor이며 `complete.log` 없음. 구현은 `agent-task/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/complete.log` 또는 matching archive complete가 생긴 뒤 시작해야 한다.
- 이 plan은 `02+01_provider_tunnel_auth`로 01에만 의존한다.
- `03+01,02_responses_passthrough`는 이 task 완료 후 시작한다.

### 범위 결정 근거

- `~/.claude/anthropic_key.sh`, local env, Codex config는 읽지 않는다.
- 실제 token 값을 config, metadata, log, test output에 남기지 않는다.
- proto/service/Node adapter 변경은 하지 않는다. `SubmitProviderTunnelRequest.Headers`와 `ProviderTunnelRequest.headers`가 이미 있다.
- `/v1/responses` passthrough는 03에서 처리한다. 이 task는 Chat Completions provider tunnel auth에 집중한다.

### 빌드 등급

`cloud-G07`. secret boundary, inbound/outbound auth header separation, external provider compatibility가 핵심이라 handler-only 수정처럼 보여도 API behavior 판단이 중요하다.

## 의존 관계 및 구현 순서

이 plan의 디렉터리명 `02+01_provider_tunnel_auth`는 predecessor index `01`을 요구한다. 구현 시작 전 `agent-task/m-seulgivibe-openai-compatible-provider/01_config_auth_catalog/complete.log` 또는 matching archive complete가 있어야 한다. predecessor가 없으면 구현하지 말고 review stub에 verification blocker로 기록한다.

## 구현 체크리스트

- [ ] [SEULGI_TUNNEL-1] Edge OpenAI server에 provider auth header 추출/포맷 helper를 추가한다.
- [ ] [SEULGI_TUNNEL-2] Chat Completions provider tunnel 요청에 provider auth headers를 주입하고 missing-required behavior를 테스트한다.
- [ ] `go test ./apps/edge/internal/openai -count=1`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [SEULGI_TUNNEL-1] Provider Auth Header Helper

#### 문제

`apps/edge/internal/openai/routes.go:20-29`는 inbound `Authorization`을 IOP auth로 사용한다. provider token을 같은 header에서 읽으면 IOP auth와 충돌한다. 반면 `apps/edge/internal/service/run_dispatch.go:787-814`와 `1023-1052`에는 tunnel request headers가 이미 있고, `apps/node/internal/runtime/types.go:217-231`와 `proto/iop/runtime.proto:65-78`도 headers를 전달한다.

Before:

```go
// apps/edge/internal/openai/routes.go:22
if strings.TrimSpace(s.cfg.BearerToken) != "" {
    expected := "Bearer " + s.cfg.BearerToken
    if subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), []byte(expected)) != 1 {
```

```go
// apps/edge/internal/service/run_dispatch.go:1036
headers := make(map[string]string, len(req.Headers))
for k, v := range req.Headers {
    headers[k] = v
}
```

#### 해결 방법

OpenAI server helper를 추가한다. 01의 `openai.provider_auth`가 disabled이면 nil headers를 반환한다. enabled이면 `from_header`에서 raw token을 읽고, `scheme`이 있으면 scheme이 없는 raw token에만 prefix한다. token/header 값은 metadata나 log에 쓰지 않는다.

After:

```go
func (s *Server) providerTunnelAuthHeaders(r *http.Request) (map[string]string, error) {
    auth := s.cfg.ProviderAuth
    if !auth.Enabled {
        return nil, nil
    }
    raw := strings.TrimSpace(r.Header.Get(auth.FromHeader))
    if raw == "" {
        if auth.Required {
            return nil, errProviderAuthRequired
        }
        return nil, nil
    }
    value := raw
    if scheme := strings.TrimSpace(auth.Scheme); scheme != "" && !strings.HasPrefix(strings.ToLower(raw), strings.ToLower(scheme)+" ") {
        value = scheme + " " + raw
    }
    return map[string]string{auth.TargetHeader: value}, nil
}
```

Error handling:

- missing required provider auth returns `400 invalid_request_error` before `SubmitProviderTunnel`.
- never echo `raw` token in error body/log.

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

- [ ] `apps/edge/internal/openai/stream.go` 또는 새 small helper file in same package: helper 추가.
- [ ] `apps/edge/internal/openai/server_test.go`: helper behavior는 handler-level tests로 검증.
- [ ] import 추가 시 unused import 없이 정리.

#### 테스트 작성

- `TestProviderTunnelAuthHeadersFormatsRawBearer`: raw `abc` -> `Authorization: Bearer abc`; raw `Bearer abc` -> unchanged.
- helper 단위 테스트 대신 handler test로 충분하면 별도 단위 테스트는 생략 가능하나, missing-required와 successful tunnel forwarding은 반드시 handler test로 둔다.

#### 중간 검증

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

### [SEULGI_TUNNEL-2] Chat Provider Tunnel Injection

#### 문제

`apps/edge/internal/openai/stream.go:356-375`의 `SubmitProviderTunnelRequest` 생성은 `Headers`를 설정하지 않는다. Node adapter는 `apps/node/internal/adapters/openai_compat/openai_compat.go:97-101`에서 tunnel request headers를 provider HTTP request에 적용하므로 Edge에서만 header를 넣으면 된다.

Before:

```go
// apps/edge/internal/openai/stream.go:356
tunnelReq := edgeservice.SubmitProviderTunnelRequest{
    NodeRef:       dispatch.NodeRef,
    ModelGroupKey: strings.TrimSpace(req.Model),
    Adapter:       dispatch.Adapter,
    Target:        dispatch.Target,
```

```go
// apps/node/internal/adapters/openai_compat/openai_compat.go:97
a.applyHeaders(httpReq, isJSON)
for k, v := range req.Headers {
    httpReq.Header.Set(k, v)
}
```

#### 해결 방법

`submitChatCompletionTunnel` 시작부에서 helper를 호출하고, 생성한 headers를 `SubmitProviderTunnelRequest.Headers`에 넣는다. helper 에러는 tunnel dispatch 전에 caller에게 반환한다.

After:

```go
headers, err := s.providerTunnelAuthHeaders(r)
if err != nil {
    writeError(w, http.StatusBadRequest, "invalid_request_error", "provider auth header is required")
    return nil, false
}

tunnelReq := edgeservice.SubmitProviderTunnelRequest{
    NodeRef:  dispatch.NodeRef,
    Headers: headers,
    // existing fields...
}
```

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

- [ ] `apps/edge/internal/openai/stream.go`: chat tunnel auth header injection.
- [ ] `apps/edge/internal/openai/server_test.go`: provider auth success/missing tests.
- [ ] 기존 passthrough/sideband tests가 header nil 상태에서 계속 통과하는지 확인.

#### 테스트 작성

- `TestChatProviderTunnelForwardsProviderAuthHeader`: `openai.provider_auth.enabled=true`, caller sends `X-IOP-Provider-Authorization: user-token`, fake service receives `Headers["Authorization"] == "Bearer user-token"`.
- `TestChatProviderTunnelProviderAuthRequired`: header absent, response 400, fake service receives no tunnel request.
- `TestChatProviderTunnelProviderAuthDoesNotReplaceInboundAuth`: `openai.bearer_token`와 provider auth가 동시에 있을 때 inbound `Authorization`은 IOP auth로만 쓰이고 provider header는 `X-IOP-Provider-Authorization`에서 만들어지는지 검증.

#### 중간 검증

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

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/edge/internal/openai/stream.go` | SEULGI_TUNNEL-1, SEULGI_TUNNEL-2 |
| `apps/edge/internal/openai/server_test.go` | SEULGI_TUNNEL-1, SEULGI_TUNNEL-2 |

## 최종 검증

```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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
