<!-- task=openai_reasoning_estimated_usage/01_metric_core plan=0 tag=API -->

# Plan - API

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

`CODE_REVIEW-local-G05.md`의 구현 에이전트 소유 섹션 작성까지가 구현의 마지막 단계다. 구현 후 검증 명령을 실행하고 실제 출력과 변경 내용을 review stub에 채운 뒤 active 파일을 그대로 둔 채 review ready로 보고한다. 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 막는 경우에만 review stub의 `사용자 리뷰 요청` 섹션을 근거와 함께 채우고 멈춘다. 직접 사용자에게 질문하거나, 선택지를 제시하거나, `USER_REVIEW.md`, `complete.log`, archive log를 만들지 않는다. 환경/secret/service blocker, 일반 범위 변경, 검증 증거 공백은 사용자 리뷰 요청이 아니라 검증 결과나 후속 plan 후보로 기록한다.

## 배경

dev-corp Ornith 경로는 reasoning text는 관측되지만 provider가 `reasoning_tokens`를 보고하지 않는 경우가 있다. 현재 Edge는 이 경우 `iop_openai_reasoning_observed_total`과 `iop_openai_reasoning_chars_total`만 emit하고 token 추정치는 제공하지 않는다. 목표는 provider-reported `token_type="reasoning"`의 의미를 보존하면서, provider 미보고 케이스도 운영자가 추정 토큰으로 조회할 수 있게 하는 것이다.

## 사용자 리뷰 요청 흐름

구현 중 직접 사용자에게 묻지 않는다. 선택된 Milestone lock 결정이 실제 구현을 차단하는 경우에만 `CODE_REVIEW-local-G05.md`의 `사용자 리뷰 요청` 섹션에 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 기록하고 멈춘다. 이 plan은 non-roadmap 독립 task이므로 일반 구현 판단은 plan 범위 안에서 처리한다.

## 분석 결과

### 읽은 파일

- `agent-roadmap/current.md`
- `agent-test/dev-corp/rules.md`
- `agent-test/dev-corp/edge-smoke.md`
- `agent-test/dev-corp/platform-common-smoke.md`
- `agent-contract/outer/openai-compatible-api.md`
- `agent-spec/input/openai-compatible-surface.md`
- `docs/openai-usage-grafana.md`
- `apps/edge/internal/openai/usage_metrics.go`
- `apps/edge/internal/openai/usage_metrics_test.go`

### SDD 기준

not applicable. 이 plan은 현재 Milestone 완료 체크를 주장하지 않는 non-roadmap 독립 task다.

### 테스트 환경 규칙

- test_env: `dev-corp`
- `agent-test/dev-corp/rules.md`: present/read. dev-corp Edge는 public `iop.ai.kr`, OpenAI-compatible 기본 URL은 `https://digitalplatform.iop.ai.kr/v1`, Edge metrics는 `19105`를 사용한다.
- matched profiles:
  - `agent-test/dev-corp/edge-smoke.md`: present/read. `apps/edge/internal/openai/**` 변경 시 `go test ./apps/edge/...` 또는 변경 패키지 테스트와 OpenAI-compatible 경계 확인이 필요하다.
  - `agent-test/dev-corp/platform-common-smoke.md`: present/read. 이 plan은 proto/config를 변경하지 않으므로 platform-common 필수 검증은 적용하지 않는다.
- 이 core plan의 필수 검증은 local checkout 안에서 끝난다. dev-corp 원격 preflight, 배포, Prometheus live 확인은 sibling plan `openai_reasoning_estimated_usage/02+01_dev_corp_rollout`에서 수행한다.
- fallback verification source: `agent-spec/input/openai-compatible-surface.md`의 OpenAI usage 검증 명령과 기존 Go package tests.

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

- 현재 `TestOpenAIReasoningObservedDoesNotEstimateTokens`는 reasoning text 미보고 케이스에서 token counter가 증가하지 않음을 검증한다. 신규 동작은 같은 케이스에서 별도 estimated metric이 증가해야 하므로 regression update가 필요하다.
- provider가 `reasoning_tokens`를 보고하는 경우 `token_type="reasoning"`만 증가하고 estimated metric이 증가하지 않는 guard가 없다. 새 test가 필요하다.
- metric label allowlist에 estimated metric의 `estimation_method`가 추가되면 secret/high-cardinality label 배제 검증을 확장해야 한다.

### 심볼 참조

renamed/removed symbol: none.

관련 참조:

- `apps/edge/internal/openai/usage_metrics.go`: metric label/counter 정의, `usageObservation`, `emitUsageMetrics`
- `apps/edge/internal/openai/usage_metrics_test.go`: usage metric label, token type, reasoning observation tests
- `docs/openai-usage-grafana.md`: reasoning 보조 metric 문서와 PromQL
- `agent-spec/input/openai-compatible-surface.md`: usage metric surface 설명

### 분할 판단

split decision policy를 plan 파일 선택 전에 평가했다. 공유 task group은 `openai_reasoning_estimated_usage`다.

- `01_metric_core`: source/test/docs/spec 변경. dependency 없음.
- `02+01_dev_corp_rollout`: dev-corp 배포/Prometheus/concurrency smoke. predecessor `01_metric_core`의 `complete.log`가 필요하다.

코드 변경과 dev-corp rollout은 검증 환경과 실패 원인이 다르므로 분리한다. core plan은 local-G05로 안전하게 완료하고, runtime 반영은 cloud-G07 rollout plan에서 독립 검증한다.

### 범위 결정 근거

- `iop_openai_usage_tokens_total{token_type="reasoning"}`는 provider-reported token 전용으로 유지한다. 추정값을 이 counter에 섞지 않는다.
- provider adapter, proto `Usage.reasoning_tokens`, Node tunnel parsing은 이미 provider-reported 값을 전달하므로 변경하지 않는다.
- Control Plane dashboard, billing/chargeback, request-level ledger, live cloud price sync는 제외한다.
- 현재 checkout에는 dev-corp metrics port 관련 dirty docs/test 변경이 있으므로 구현자는 같은 파일 수정 시 기존 변경을 보존한다.

### 빌드 등급

lane=`local`, grade=`G05`. 변경은 Edge OpenAI usage metric 한 파일과 해당 unit tests/docs/spec에 한정되며 local Go tests로 대부분 검증된다.

## 구현 체크리스트

- [ ] `apps/edge/internal/openai/usage_metrics.go`에 provider 미보고 reasoning text를 별도 estimated-token metric으로 emit하는 로직을 추가한다.
- [ ] `apps/edge/internal/openai/usage_metrics_test.go`에 estimated metric 증가, provider-reported reasoning 우선, label allowlist regression을 추가/갱신한다.
- [ ] `docs/openai-usage-grafana.md`와 `agent-spec/input/openai-compatible-surface.md`에 reported reasoning token과 estimated reasoning token의 차이를 문서화한다.
- [ ] 최종 검증 명령을 모두 실행하고 예상 결과와 실제 출력 차이를 review stub에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [API-1] Reasoning estimated token metric 추가

#### 문제

`apps/edge/internal/openai/usage_metrics.go:79-82`의 chars metric help는 "Never converted to a token estimate"라고 고정되어 있다. `apps/edge/internal/openai/usage_metrics.go:85-96`도 `reasoningChars`를 보조 metric에만 쓰고, `apps/edge/internal/openai/usage_metrics.go:166-169`는 observed/chars만 증가시킨다. 따라서 provider가 token count를 주지 않는 think text는 token 단위로 조회할 수 없다.

#### 해결 방법

`iop_openai_usage_tokens_total`은 provider-reported 전용으로 유지하고, 별도 counter를 추가한다.

Before:

```go
// apps/edge/internal/openai/usage_metrics.go:79
openAIReasoningCharsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
	Name: "iop_openai_reasoning_chars_total",
	Help: "Observed reasoning text characters for requests without provider-reported reasoning tokens. Never converted to a token estimate.",
}, usageReasoningLabelNames)
```

After:

```go
usageReasoningEstimateLabelNames = []string{
	"edge_id", "principal_ref", "principal_alias", "token_ref",
	"model_group", "endpoint", "response_mode", "estimation_method",
}

openAIReasoningEstimatedTokensTotal = promauto.NewCounterVec(prometheus.CounterOpts{
	Name: "iop_openai_reasoning_estimated_tokens_total",
	Help: "Estimated reasoning tokens for requests with observed reasoning text but without provider-reported reasoning token usage.",
}, usageReasoningEstimateLabelNames)
```

`emitUsageMetrics`에서 `obs.reasoningChars > 0 && obs.reasoningTokens == 0`일 때만 `ceil(chars / 4)`를 `estimation_method="chars_div_4"`로 emit한다. `math` 표준 패키지를 쓰거나 integer helper `(chars + 3) / 4`를 사용한다. provider가 `reasoningTokens > 0`이면 estimated metric은 증가하지 않는다.

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

- [ ] `apps/edge/internal/openai/usage_metrics.go`: estimated metric label set과 counter 추가
- [ ] `apps/edge/internal/openai/usage_metrics.go`: `estimatedReasoningTokens(chars int) int` helper 추가
- [ ] `apps/edge/internal/openai/usage_metrics.go`: observed/chars emit path에서 estimated counter도 emit
- [ ] `apps/edge/internal/openai/usage_metrics.go`: comments/help text를 "estimated는 별도 metric" 기준으로 갱신

#### 테스트 작성

write tests.

- `TestOpenAIReasoningObservedEstimatesTokens`: 5 chars reasoning text가 provider reasoning token 없이 오면 estimated delta가 `2`, observed delta가 `1`, chars delta가 `5`, `token_type="reasoning"` delta가 `0`.
- `TestOpenAIReasoningProviderReportedDoesNotEstimate`: provider가 `ReasoningTokens: 3`을 주고 reasoning text도 있으면 estimated delta가 `0`, `token_type="reasoning"` delta가 `3`.
- `TestOpenAIUsageMetricsLabelsExcludeSecretsAndHighCardinality`: `usageReasoningEstimateLabelNames`를 forbidden-label loop에 포함하고 `estimation_method`만 추가 허용.

#### 중간 검증

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

Expected: PASS. Go test cache output is not acceptable here; use `-count=1`.

### [API-2] Usage metric regression tests 정리

#### 문제

`apps/edge/internal/openai/usage_metrics_test.go:144-189`는 "DoesNotEstimateTokens"라는 기존 정책을 직접 고정한다. 새 정책은 provider-reported token counter에는 추정치를 섞지 않는 대신 별도 estimated counter를 제공해야 하므로 테스트 이름과 assertion이 현재 목표와 어긋난다.

#### 해결 방법

기존 테스트를 rename/update하고, provider-reported 우선 테스트를 추가한다. 테스트 helper는 `requestTokenValue`처럼 estimated counter 전용 helper를 하나 둔다.

Before:

```go
// apps/edge/internal/openai/usage_metrics_test.go:180
if got := requestTokenValue(t, labels, tokenTypeReasoning) - reasoningBefore; got != 0 {
	t.Fatalf("reasoning token counter must not move when provider did not report reasoning tokens: got delta %v", got)
}
```

After:

```go
if got := requestTokenValue(t, labels, tokenTypeReasoning) - reasoningBefore; got != 0 {
	t.Fatalf("provider-reported reasoning token counter must not move for estimated reasoning: got delta %v", got)
}
if got := requestReasoningEstimateValue(t, labels, "chars_div_4") - estimatedBefore; got != 2 {
	t.Fatalf("reasoning estimated tokens: got delta %v, want 2", got)
}
```

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

- [ ] `apps/edge/internal/openai/usage_metrics_test.go`: estimated counter helper 추가
- [ ] `apps/edge/internal/openai/usage_metrics_test.go`: reasoning observed test rename/update
- [ ] `apps/edge/internal/openai/usage_metrics_test.go`: provider-reported reasoning + text no-estimate test 추가
- [ ] `apps/edge/internal/openai/usage_metrics_test.go`: label allowlist test에 estimated labels 포함

#### 테스트 작성

write tests as listed above. 기존 passthrough usage tests는 provider-reported `reasoning_tokens`가 기존 counter로 유지되는 regression으로 그대로 둔다.

#### 중간 검증

```bash
GOCACHE=/tmp/iop-go-cache go test -count=1 ./apps/edge/internal/openai -run 'OpenAI.*Usage|OpenAI.*Reasoning|ProviderTunnelPassthrough.*Usage|ResponsesProviderTunnelPassthrough.*Usage'
```

Expected: PASS. Go test cache output is not acceptable here; use `-count=1`.

### [API-3] Grafana/spec 문서 갱신

#### 문제

`docs/openai-usage-grafana.md:40-48`은 observed/chars metric만 설명하고, `docs/openai-usage-grafana.md:185`는 token 추정을 권유하지 않는다고 적는다. `agent-spec/input/openai-compatible-surface.md:66-68`, `agent-spec/input/openai-compatible-surface.md:124-128`, `agent-spec/input/openai-compatible-surface.md:155-156`도 reasoning text를 token으로 추정하지 않는 현 상태를 고정한다.

#### 해결 방법

문서에서 provider-reported token과 estimated token을 분리해 설명한다. `iop_openai_reasoning_estimated_tokens_total`은 운영 관측용 추정값이며 billing-grade/chargeback 확정값이 아니라고 명시한다.

Before:

```markdown
Observed reasoning text characters for requests without provider-reported reasoning tokens. Never converted to a token estimate.
```

After:

```markdown
Observed reasoning text characters for requests without provider-reported reasoning tokens. The Edge also emits `iop_openai_reasoning_estimated_tokens_total` as an explicit estimate; provider-reported `token_type="reasoning"` remains authoritative.
```

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

- [ ] `docs/openai-usage-grafana.md`: Metric 목록에 `iop_openai_reasoning_estimated_tokens_total` 추가
- [ ] `docs/openai-usage-grafana.md`: PromQL 예시와 dashboard 후보에 estimated reasoning token query 추가
- [ ] `docs/openai-usage-grafana.md`: cloud-equivalent cost 섹션에서 estimated reasoning은 확정 정산값이 아니라고 명시
- [ ] `agent-spec/input/openai-compatible-surface.md`: 기능 목록, 설정/데이터/이벤트, 한계 문구를 updated metric 기준으로 갱신

#### 테스트 작성

skip new executable tests. 문서/spec 변경은 final `rg --sort path --fixed-strings`로 신규 metric 명칭이 source/docs/spec에 모두 반영됐는지 확인한다.

#### 중간 검증

```bash
rg --sort path --fixed-strings "iop_openai_reasoning_estimated_tokens_total" apps/edge/internal/openai docs/openai-usage-grafana.md agent-spec/input/openai-compatible-surface.md
```

Expected: source, test, docs, spec references are all present.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/edge/internal/openai/usage_metrics.go` | API-1 |
| `apps/edge/internal/openai/usage_metrics_test.go` | API-2 |
| `docs/openai-usage-grafana.md` | API-3 |
| `agent-spec/input/openai-compatible-surface.md` | API-3 |

## 최종 검증

```bash
GOCACHE=/tmp/iop-go-cache go test -count=1 ./apps/edge/internal/openai -run 'UsageMetrics|Reasoning'
GOCACHE=/tmp/iop-go-cache go test -count=1 ./apps/edge/internal/openai -run 'OpenAI.*Usage|OpenAI.*Reasoning|ProviderTunnelPassthrough.*Usage|ResponsesProviderTunnelPassthrough.*Usage'
GOCACHE=/tmp/iop-go-cache go test -count=1 ./apps/edge/internal/openai
rg --sort path --fixed-strings "iop_openai_reasoning_estimated_tokens_total" apps/edge/internal/openai docs/openai-usage-grafana.md agent-spec/input/openai-compatible-surface.md
git diff --check
```

Expected: all commands PASS. Go test cache output is not acceptable for the Go commands; `-count=1` is required.

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