<!-- task=m-model-group-mixed-provider-dispatch/05_sideband_observation plan=0 tag=SURFACE_OBS -->

# Plan - SURFACE_OBS

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

`CODE_REVIEW-local-G07.md`의 구현 에이전트 소유 섹션을 채우는 것까지가 구현이다. 코드를 바꾸고 검증을 실행한 뒤 실제 변경 내용과 stdout/stderr를 기록하고 active 파일은 그대로 둔 채 리뷰 준비를 보고한다. 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 구현을 막을 때만 review stub의 `사용자 리뷰 요청`을 채우고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `USER_REVIEW.md`, `complete.log`, archive log를 만들지 않는다.

## 배경

model group은 selected provider가 실행 경로를 결정하지만, 현재 `RunDispatch`에는 `queue_reason`은 있어도 selected provider id/type과 execution path가 없다. OpenAI handler log와 sideband observation은 adapter/target 중심이라, model group 관측에서 어떤 provider와 경로가 선택됐는지 일관되게 추적하기 어렵다. 이 작업은 표준 client 응답을 깨끗하게 유지하면서 service dispatch DTO와 internal log/metric/sideband 관측을 보강한다.

## 사용자 리뷰 요청 흐름

사용자 리뷰 요청은 선택된 Milestone lock 결정이 구현을 실제로 막을 때만 active `CODE_REVIEW-local-G07.md`의 `사용자 리뷰 요청` 섹션에 기록한다. 직접 사용자 프롬프트, 채팅 선택지, `request_user_input` 호출은 금지하며, code-review가 요청 유효성 검증과 `USER_REVIEW.md` 작성을 소유한다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md`
- Milestone link: [Milestone 문서](agent-roadmap/phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md)
- Task ids:
  - `sideband-observation`: Provider-pool 실행 결과 selected provider, adapter, served target, execution path, queue decision, usage observation 기록
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `agent-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `agent-roadmap/phase/routing-policy-model-orchestration/milestones/model-group-mixed-provider-dispatch.md`
- `agent-roadmap/sdd/routing-policy-model-orchestration/model-group-mixed-provider-dispatch/SDD.md`
- `agent-contract/outer/openai-compatible-api.md`
- `agent-spec/input/openai-compatible-surface.md`
- `apps/edge/internal/service/model_queue.go`
- `apps/edge/internal/service/run_dispatch.go`
- `apps/edge/internal/service/model_queue_test.go`
- `apps/edge/internal/service/service_test.go`
- `apps/edge/internal/service/run_dispatch_internal_test.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/usage_metrics.go`
- `apps/edge/internal/openai/usage_metrics_test.go`
- `apps/edge/internal/openai/server_test.go`

### SDD 기준

- SDD: `agent-roadmap/sdd/routing-policy-model-orchestration/model-group-mixed-provider-dispatch/SDD.md`, 상태 `[승인됨]`, 잠금 해제.
- 대상 Acceptance Scenario: S09.
- Evidence Map: S09는 standard response와 observation tests를 요구한다.
- 구현 체크리스트는 service dispatch info 보강, OpenAI log/sideband observation 반영, standard response no-custom-field regression, service/openai package tests에서 역산했다.

### 테스트 환경 규칙

- test_env: `local`.
- `agent-test/local/rules.md`와 `agent-test/local/edge-smoke.md`를 읽었다.
- Edge/OpenAI-compatible와 service dispatch DTO 변경이므로 `go test ./apps/edge/internal/openai -count=1`와 `go test ./apps/edge/internal/service -count=1`을 최종 검증으로 둔다.
- 두 명령은 현재 checkout에서 실제 실행 확인됨. 외부 provider, secret, Docker, 장기 runtime 프리플라이트는 필요 없다.

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

- service `RunDispatch`는 selected provider id/type/execution path를 노출하지 않으므로 이를 검증하는 테스트가 없다.
- OpenAI provider-pool dispatch logs는 path/adapter/target/queue_reason 일부만 검증되고, standard response에 IOP custom observation이 섞이지 않는 provider-pool-specific regression이 부족하다.
- usage metrics는 token/endpoint/response_mode 중심 검증이 있으나 selected execution path가 dispatch info/log와 일치하는지 확인하지 않는다.

### 심볼 참조

- renamed/removed symbol 없음.
- `RunDispatch` 구조체 필드 추가가 예상되며 named composite literal call sites는 `rg 'RunDispatch{' apps/edge/internal` 결과 기준으로 service/openai tests에 있다.

### 분할 판단

- split decision policy를 먼저 평가했다.
- 공유 task group: `m-model-group-mixed-provider-dispatch`.
- sibling plan:
  - `04_custom_field_preservation`: 독립.
  - `05_sideband_observation`: 독립.
- 이 plan은 service DTO와 OpenAI observation/log/test를 함께 다루며, custom-field raw body preservation과 구현/검증 축이 달라 별도 subtask가 안전하다. predecessor 없음.

### 범위 결정 근거

- Prometheus raw prompt/response나 secret/high-cardinality payload label은 추가하지 않는다.
- 표준 OpenAI-compatible response body/header에는 sideband/custom field를 추가하지 않는다.
- direct legacy `passthrough+sideband` extension schema는 유지하되, provider-pool model group request selector는 다시 추가하지 않는다.
- contract/spec 전체 문구 정리는 `contract-sync` task 범위로 남긴다.

### 빌드 등급

- `local-G07`: service dispatch DTO, OpenAI logs/sideband, tests 두 패키지에 걸친 변경이며 로컬 deterministic Go tests로 검증 가능하다.

## 구현 체크리스트

- [ ] [SURFACE_OBS-1] service provider-pool dispatch info에 selected provider id/type과 execution path를 추가하고 tunnel/normalized 양쪽에서 채운다.
- [ ] [SURFACE_OBS-2] OpenAI provider-pool dispatch logs와 direct sideband route observation이 selected provider/adapter/target/execution path/queue reason을 기록하도록 보강한다.
- [ ] [SURFACE_OBS-3] 표준 provider-pool Chat response에는 `iop_sideband`, `iop.sideband`, `X-IOP-Response-Mode`가 섞이지 않고, internal observation만 남음을 regression test로 검증한다. 검증: `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'ProviderPool.*Observation|AssembledLogs|UsageMetrics' -count=1`.
- [ ] [SURFACE_OBS-4] service와 OpenAI 전체 패키지 검증을 실행한다. 검증: `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/service -count=1` 및 `GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -count=1`.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [SURFACE_OBS-1] Provider-Pool Dispatch Observation DTO

#### 문제

- [run_dispatch.go](/config/workspace/iop/apps/edge/internal/service/run_dispatch.go:53): `RunDispatch`에 `ProviderID`, `ProviderType`, `ExecutionPath`가 없다.
- [model_queue.go](/config/workspace/iop/apps/edge/internal/service/model_queue.go:45): `candidateNode`는 provider id와 execution path를 갖지만 provider type은 보존하지 않는다.
- [run_dispatch.go](/config/workspace/iop/apps/edge/internal/service/run_dispatch.go:1013): `SubmitProviderPool` tunnel result의 `DispatchInfo`가 selected provider 관측 필드를 전달할 수 없다.
- [run_dispatch.go](/config/workspace/iop/apps/edge/internal/service/run_dispatch.go:1083): normalized provider-pool `RunDispatch`도 같은 공백이 있다.

#### 해결 방법

Before ([run_dispatch.go](/config/workspace/iop/apps/edge/internal/service/run_dispatch.go:53)):

```go
type RunDispatch struct {
    RunID         string
    NodeID        string
    ModelGroupKey string
    Adapter       string
    Target        string
    QueueReason   string
}
```

After:

```go
type RunDispatch struct {
    ...
    ProviderID    string
    ProviderType  string
    ExecutionPath string
    QueueReason   string
}
```

`candidateNode`에 `providerType`을 추가하고 `resolveProviderPoolCandidates`에서 `prov.Type`을 저장한다. provider-pool tunnel/normalized dispatch에서 `ProviderID`, `ProviderType`, `ExecutionPath`를 채우고 legacy direct dispatch는 빈 값으로 둔다.

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

- [ ] `apps/edge/internal/service/model_queue.go`: `candidateNode.providerType` 추가.
- [ ] `apps/edge/internal/service/run_dispatch.go`: `RunDispatch` 필드 추가와 provider-pool fill path 보강.
- [ ] `apps/edge/internal/service/run_dispatch_internal_test.go` 또는 `model_queue_test.go`: selected provider observation regression 추가.

#### 테스트 작성

- 작성한다. Assertion 목표: SubmitProviderPool tunnel/normalized result가 provider id/type/execution path/queue reason/adapter/target을 모두 담는다.

#### 중간 검증

```bash
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/service -run 'SubmitProviderPool.*Observation|DispatchInfo|ClassifiesExecutionPath' -count=1
```

### [SURFACE_OBS-2] OpenAI Observation And Standard Response Boundary

#### 문제

- [chat_handler.go](/config/workspace/iop/apps/edge/internal/openai/chat_handler.go:277): provider-pool log가 path/adapter/target/queue_reason은 기록하지만 provider id/type/execution path 필드가 없다.
- [stream.go](/config/workspace/iop/apps/edge/internal/openai/stream.go:746): sideband route observation schema는 provider status까지 있으나 provider id/type/execution path/queue reason이 없다.
- [stream.go](/config/workspace/iop/apps/edge/internal/openai/stream.go:465): pure passthrough writer는 body를 relay하고 metrics를 emit하지만 표준 response에 custom sideband를 섞지 않는 provider-pool-specific regression이 필요하다.

#### 해결 방법

OpenAI log fields에 `provider_id`, `provider_type`, `execution_path`, `queue_reason`을 추가한다. direct legacy sideband route는 빈 provider fields를 omit할 수 있게 `omitempty`를 사용하고, provider-pool standard response는 sideband extension writer를 타지 않게 유지한다.

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

- [ ] `apps/edge/internal/openai/chat_handler.go`: provider-pool dispatch log fields 추가.
- [ ] `apps/edge/internal/openai/responses_handler.go`: provider tunnel/sideband logs가 새 dispatch fields를 보존하도록 추가.
- [ ] `apps/edge/internal/openai/stream.go`: sideband route observation schema에 provider/execution/queue fields 추가.
- [ ] `apps/edge/internal/openai/server_test.go`: observer logger 기반 regression 추가.

#### 테스트 작성

- 작성한다. Assertion 목표: provider-pool standard response body/header에는 IOP custom sideband가 없고, observer logs에는 selected provider/execution/queue fields가 있다.

#### 중간 검증

```bash
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'ProviderPool.*Observation|AssembledLogs' -count=1
```

### [SURFACE_OBS-3] Usage Observation Regression

#### 문제

- [usage_metrics.go](/config/workspace/iop/apps/edge/internal/openai/usage_metrics.go:49): usage labels는 low-cardinality allowlist를 유지한다.
- [stream.go](/config/workspace/iop/apps/edge/internal/openai/stream.go:487): pure passthrough usage observation은 provider body/proto usage에서 metrics를 emit한다.
- SDD S09는 usage 후보가 internal observation에 남아야 하지만 표준 client response에는 custom field가 없어야 한다.

#### 해결 방법

Prometheus labels에는 raw prompt/response/secret을 추가하지 않는다. provider id label 추가가 필요하다고 판단되면 configured provider id만 low-cardinality로 추가하고 label allowlist test를 함께 수정한다. 그렇지 않으면 provider id/type/path는 logs/dispatch sideband, usage 후보는 기존 usage metrics로 분리 기록한다. 선택한 해석은 `주요 설계 결정`에 기록한다.

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

- [ ] `apps/edge/internal/openai/usage_metrics.go`: label 변경 여부를 확정하고 필요 시 allowlist 업데이트.
- [ ] `apps/edge/internal/openai/usage_metrics_test.go`: metric allowlist와 provider-pool standard response metric regression 업데이트.
- [ ] `apps/edge/internal/openai/server_test.go`: response body/header no-sideband assertion 추가 또는 보강.

#### 테스트 작성

- 작성한다. Assertion 목표: usage metrics는 계속 emit되고 표준 response에는 observation custom field가 없다.

#### 중간 검증

```bash
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -run 'UsageMetrics|ProviderPool.*Observation' -count=1
```

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/edge/internal/service/model_queue.go` | SURFACE_OBS-1 |
| `apps/edge/internal/service/run_dispatch.go` | SURFACE_OBS-1 |
| `apps/edge/internal/service/run_dispatch_internal_test.go` | SURFACE_OBS-1 |
| `apps/edge/internal/openai/chat_handler.go` | SURFACE_OBS-2 |
| `apps/edge/internal/openai/responses_handler.go` | SURFACE_OBS-2 |
| `apps/edge/internal/openai/stream.go` | SURFACE_OBS-2 |
| `apps/edge/internal/openai/server_test.go` | SURFACE_OBS-2, SURFACE_OBS-3 |
| `apps/edge/internal/openai/usage_metrics.go` | SURFACE_OBS-3 |
| `apps/edge/internal/openai/usage_metrics_test.go` | SURFACE_OBS-3 |

## 최종 검증

```bash
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/service -count=1
GOCACHE=/tmp/iop-go-cache go test ./apps/edge/internal/openai -count=1
```

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