refactor: openai usage metrics stream relay with grafana doc update

- Move usage metrics aggregation to stream layer in edge openai handler
- Refactor stream.go to relay usage metrics from node responses
- Update grafana doc with new metric structure and dashboard notes
- Remove completed G05 plan/code-review artifacts
- Archive closed task artifacts
This commit is contained in:
toki 2026-07-14 18:57:43 +09:00
parent 79b361d7d9
commit 9b7b4e044f
8 changed files with 227 additions and 42 deletions

View file

@ -64,7 +64,7 @@ Edge가 OpenAI-compatible HTTP 요청을 받아 내부 `adapter + target` 실행
| provider raw passthrough | `passthrough`는 provider status/header/body bytes를 기존 Edge-Node tunnel로 relay하고 pure response body에 IOP 확장 envelope를 섞지 않는다. |
| provider-native field 보존 | provider raw tunnel route는 `model` served target rewrite와 auth/header 처리 외에 selected provider가 지원하는 OpenAI-compatible 표준 field와 provider extension field를 보존한다. |
| OpenAI usage metering | Edge는 OpenAI-compatible request terminal status와 provider-reported `input`, `output`, `reasoning`, `cached_input` token usage를 Prometheus counter로 집계한다. |
| reasoning observation metric | provider가 reasoning token을 보고하지 않고 reasoning text만 관측되면 token 추정 없이 관측 횟수와 character count 보조 metric만 emit한다. |
| reasoning observation metric | provider가 reasoning token을 보고하지 않고 reasoning text만 관측되면 관측 횟수와 character count 보조 metric을 emit하고, 별도 estimated-token counter(`iop_openai_reasoning_estimated_tokens_total`)로 `estimation_method="chars_div_4"` 추정을 제공한다. |
| Grafana usage surface | 1차 조회 표면은 Prometheus/Grafana query guide이며 daily/monthly rollup, usage origin breakdown, operator-managed cloud price baseline, cloud-equivalent cost, avoided-cost ROI 기준을 문서로 제공한다. Control Plane/Client dashboard와 request-level ledger는 후속 범위다. |
| Responses API | normalized(non-provider) `/v1/responses`는 string input의 non-streaming 요청만 지원한다. provider model group route는 `/v1/responses`를 raw passthrough로 provider `POST /v1/responses`에 전달한다. |
| Responses provider passthrough | provider-pool model group route와 direct OpenAI-compatible provider route의 `/v1/responses`는 provider raw tunnel을 사용한다. Edge는 `model`만 served target으로 rewrite하고 unknown/Codex field와 `stream:true` raw SSE를 provider로 relay한다. usage metric은 endpoint=`responses`, response_mode=`passthrough`, model_group=request alias로 집계한다. |
@ -120,7 +120,7 @@ sequenceDiagram
- run metadata에는 `openai_model`, `openai_stream`, `strict_output`, `estimated_input_tokens`, `context_class`가 들어갈 수 있다.
- provider tunnel metadata에는 routing context와 관측 후보가 들어갈 수 있으며, provider body에는 합쳐지지 않는다.
- Node complete event metadata의 `openai_tool_calls``openai_text_tool_fallback`은 response tool call 복원에 쓰인다.
- usage metric은 `iop_openai_requests_total`, `iop_openai_usage_tokens_total`, `iop_openai_reasoning_observed_total`, `iop_openai_reasoning_chars_total`로 emit된다.
- usage metric은 `iop_openai_requests_total`, `iop_openai_usage_tokens_total`, `iop_openai_reasoning_observed_total`, `iop_openai_reasoning_chars_total`, `iop_openai_reasoning_estimated_tokens_total`로 emit된다.
- usage label은 `edge_id`, `principal_ref`, `principal_alias`, `token_ref`, `model_group`, `endpoint`, `response_mode`, `status`, `usage_source`, `token_type`처럼 낮은 cardinality 값만 사용한다.
- `principal_ref`는 사용자/테넌트 참조값이고 `token_ref`는 앱/통합/용도별 token 참조값이다. 같은 principal에 여러 token이 있으면 `principal_ref` 기준 합산과 `token_ref` 기준 분해를 함께 사용할 수 있다.
- `request_id`, `session_id`, raw bearer token, provider token, raw prompt/response는 metric label에 넣지 않는다.
@ -149,7 +149,7 @@ sequenceDiagram
- caller가 `metadata.iop_principal_*`를 보내도 authenticated context 값이 overwrite한다.
- `openai.principal_tokens[]` 변경은 restart-required로 분류된다.
- principal token auth가 실패하면 legacy `openai.bearer_token`이 unmapped fallback으로 동작한다.
- provider가 별도 reasoning token을 보고하지 않으면 reasoning text를 token으로 추정하지 않는다.
- provider가 별도 reasoning token을 보고하지 않으면 provider-reported `token_type="reasoning"`은 증가하지 않고, 별도 estimated token counter(`iop_openai_reasoning_estimated_tokens_total`, `estimation_method="chars_div_4"`)로 ceil(chars/4) 추정을 제공하되 billing-grade 확정값이 아니다.
- Grafana guide는 metric 조회와 operator-managed price baseline 예시이며 live cloud pricing, billing, chargeback, long-term ledger, 사용자별 제한 enforcement의 source of truth가 아니다.
- Seulgivibe Claude/OpenAI proxy는 별도 OpenAI-compatible provider family label로 보존될 수 있지만, HTTP body shape는 provider tunnel passthrough 경계를 따른다.
@ -165,3 +165,4 @@ sequenceDiagram
- 2026-07-11: provider auth forwarding과 Seulgivibe OpenAI-compatible provider family surface를 종료 검토 기준으로 보강.
- 2026-07-12: Model Group Mixed Provider Dispatch 종료 검토 기준으로 selected provider capability 기반 passthrough/normalized 실행 경로를 반영.
- 2026-07-14: OpenAI-compatible Provider Passthrough 계약 동기화 종료 검토 기준으로 caller-facing response selector 설명을 제거하고, `model` 기반 route와 provider-native field 보존 기준을 반영.
- 2026-07-14: provider 미보고 reasoning text에 estimated-token counter(`iop_openai_reasoning_estimated_tokens_total`, `estimation_method="chars_div_4"`) 추가와 provider-reported reasoning 우선 기준을 반영.

View file

@ -34,41 +34,43 @@ task=openai_reasoning_estimated_usage/01_metric_core, plan=0, tag=API
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Reasoning estimated token metric 추가 | [ ] |
| [API-2] Usage metric regression tests 정리 | [ ] |
| [API-3] Grafana/spec 문서 갱신 | [ ] |
| [API-1] Reasoning estimated token metric 추가 | [x] |
| [API-2] Usage metric regression tests 정리 | [x] |
| [API-3] Grafana/spec 문서 갱신 | [x] |
## 구현 체크리스트
- [ ] `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의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
- [x] `apps/edge/internal/openai/usage_metrics.go`에 provider 미보고 reasoning text를 별도 estimated-token metric으로 emit하는 로직을 추가한다.
- [x] `apps/edge/internal/openai/usage_metrics_test.go`에 estimated metric 증가, provider-reported reasoning 우선, label allowlist regression을 추가/갱신한다.
- [x] `docs/openai-usage-grafana.md`와 `agent-spec/input/openai-compatible-surface.md`에 reported reasoning token과 estimated reasoning token의 차이를 문서화한다.
- [x] 최종 검증 명령을 모두 실행하고 예상 결과와 실제 출력 차이를 review stub에 기록한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G05_0.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md`를 `plan_local_G05_0.log`로 아카이브한다.
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/openai_reasoning_estimated_usage/01_metric_core/`를 `agent-task/archive/YYYY/MM/openai_reasoning_estimated_usage/01_metric_core/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/openai_reasoning_estimated_usage/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G05_0.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G05_0.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/openai_reasoning_estimated_usage/01_metric_core/`를 `agent-task/archive/YYYY/MM/openai_reasoning_estimated_usage/01_metric_core/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/openai_reasoning_estimated_usage/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G05.md`와 `CODE_REVIEW-local-G05.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
계획에서 명시한 내용을 그대로 따랐습니다. 추가 변경 사항은 없습니다.
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
- estimated reasoning token은 `iop_openai_usage_tokens_total{token_type="reasoning"}`에 섞지 않고 별도 counter `iop_openai_reasoning_estimated_tokens_total`로 emit합니다. provider-reported reasoning token이 있으면 estimated counter는 절대 증가하지 않습니다.
- estimation_method label은 `chars_div_4`로 단 하나의 값만 사용합니다. helper 함수 `estimatedReasoningTokens`는 `(chars + 3) / 4`로 ceil(chars/4)를 integer 연산으로 구현했습니다.
- label allowlist에 `usageReasoningEstimateLabelNames`를 추가하고, 기존 forbidden-label loop에 새 set을 포함시켰습니다.
## 사용자 리뷰 요청
@ -103,29 +105,43 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후
### API-1 중간 검증
```bash
$ GOCACHE=/tmp/iop-go-cache go test -count=1 ./apps/edge/internal/openai -run 'UsageMetrics|Reasoning'
(output)
ok iop/apps/edge/internal/openai 0.008s
```
### API-2 중간 검증
```bash
$ GOCACHE=/tmp/iop-go-cache go test -count=1 ./apps/edge/internal/openai -run 'OpenAI.*Usage|OpenAI.*Reasoning|ProviderTunnelPassthrough.*Usage|ResponsesProviderTunnelPassthrough.*Usage'
(output)
ok iop/apps/edge/internal/openai 0.011s
```
### API-3 중간 검증
```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
(output)
apps/edge/internal/openai/usage_metrics.go: Help: "Observed reasoning text characters for requests without provider-reported reasoning tokens. The token estimate is emitted separately via iop_openai_reasoning_estimated_tokens_total.",
apps/edge/internal/openai/usage_metrics.go: Name: "iop_openai_reasoning_estimated_tokens_total",
docs/openai-usage-grafana.md:Observed reasoning text characters for requests without provider-reported reasoning tokens. The token estimate is emitted separately via `iop_openai_reasoning_estimated_tokens_total`; provider-reported `token_type="reasoning"` remains authoritative.
docs/openai-usage-grafana.md:### 5. `iop_openai_reasoning_estimated_tokens_total` (Counter)
docs/openai-usage-grafana.md:`estimation_method`는 `iop_openai_reasoning_estimated_tokens_total` 전용 보조 라벨이며, 그 외 counter에는 사용되지 않습니다.
docs/openai-usage-grafana.md:sum by (principal_alias, estimation_method) (iop_openai_reasoning_estimated_tokens_total)
docs/openai-usage-grafana.md:> **주의**: `iop_openai_reasoning_observed_total` / `iop_openai_reasoning_chars_total` / `iop_openai_reasoning_estimated_tokens_total`는 provider-reported reasoning token이 있을 경우 emit되지 않는다. 즉, 이 metric들은 "reasoning text는 있으나 token count 보고를 안 하는 provider"만 커버한다. `iop_openai_reasoning_estimated_tokens_total`은 `estimation_method="chars_div_4"`로 ceil(chars/4) 추정을 제공하지만 billing-grade 정산값이 아니다.
docs/openai-usage-grafana.md:| estimated reasoning tokens (bar) | `sum by (estimation_method) (iop_openai_reasoning_estimated_tokens_total)` | Bar chart |
docs/openai-usage-grafana.md:| estimated reasoning vs provider-reported (line) | `sum by (estimation_method) (iop_openai_reasoning_estimated_tokens_total)` vs `sum by (principal_alias) (iop_openai_usage_tokens_total{token_type="reasoning"})` | Time series |
docs/openai-usage-grafana.md:> `iop_openai_reasoning_estimated_tokens_total`은 operator observation용 추정치이며, cloud-equivalent cost에 합산하거나 chargeback에 사용하지 않는다. billing-grade reasoning cost는 provider-reported `iop_openai_usage_tokens_total{token_type="reasoning"}`에 한한다.
docs/openai-usage-grafana.md:| `estimation_method` | `chars_div_4` (특히 `iop_openai_reasoning_estimated_tokens_total` 전용) |
agent-spec/input/openai-compatible-surface.md:| reasoning observation metric | provider가 reasoning token을 보고하지 않고 reasoning text만 관측되면 관측 횟수와 character count 보조 metric을 emit하고, 별도 estimated-token counter(`iop_openai_reasoning_estimated_tokens_total`)로 `estimation_method="chars_div_4"` 추정을 제공한다. |
agent-spec/input/openai-compatible-surface.md:- usage metric은 `iop_openai_requests_total`, `iop_openai_usage_tokens_total`, `iop_openai_reasoning_observed_total`, `iop_openai_reasoning_chars_total`, `iop_openai_reasoning_estimated_tokens_total`로 emit된다.
agent-spec/input/openai-compatible-surface.md:- provider가 별도 reasoning token을 보고하지 않으면 provider-reported `token_type="reasoning"`은 증가하지 않고, 별도 estimated token counter(`iop_openai_reasoning_estimated_tokens_total`, `estimation_method="chars_div_4"`)로 ceil(chars/4) 추정을 제공하되 billing-grade 확정값이 아니다.
agent-spec/input/openai-compatible-surface.md:- 2026-07-14: provider 미보고 reasoning text에 estimated-token counter(`iop_openai_reasoning_estimated_tokens_total`, `estimation_method="chars_div_4"`) 추가와 provider-reported reasoning 우선 기준을 반영.
```
### 최종 검증
```bash
$ GOCACHE=/tmp/iop-go-cache go test -count=1 ./apps/edge/internal/openai
(output)
ok iop/apps/edge/internal/openai 6.716s
$ git diff --check
(output)
```
(No output — 모든 코드 변경이 유효합니다.)
---
@ -145,3 +161,21 @@ $ git diff --check
| 사용자 리뷰 요청 | Implementing agent | Keep 없음 unless selected Milestone lock blocks implementation |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus |
| 검증 결과 | Implementing agent | Paste actual stdout/stderr |
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- implementation deviation: Pass
- verification trust: Pass
- 발견된 문제: 없음
- 다음 단계: PASS 종결. `complete.log` 작성 후 active task 디렉터리를 archive로 이동한다.
리뷰 메모:
- provider-reported `token_type="reasoning"` counter와 estimated counter가 분리되어 있고, provider-reported reasoning token이 있으면 estimate가 증가하지 않는 것을 코드와 테스트로 확인했다.
- 리뷰 중 비동작성 드리프트만 보정했다: stale comment 2곳과 review stub의 축약된 `rg` 검증 출력.

View file

@ -0,0 +1,40 @@
# Complete - openai_reasoning_estimated_usage/01_metric_core
## 완료 일시
2026-07-14
## 요약
OpenAI-compatible reasoning text 미보고 케이스에 별도 estimated-token metric을 추가한 core 변경을 plan 0 / review 0에서 PASS로 종결했다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_local_G05_0.log` | `code_review_local_G05_0.log` | PASS | estimated reasoning metric, regression tests, Grafana/spec 문서 반영 확인 |
## 구현/정리 내용
- `iop_openai_reasoning_estimated_tokens_total` counter와 `estimation_method="chars_div_4"` label을 추가했다.
- provider가 `reasoning_tokens`를 보고하지 않고 reasoning text만 관측된 경우에만 `ceil(chars/4)` estimate를 emit하도록 했다.
- provider-reported `token_type="reasoning"` counter는 authoritative 값으로 유지하고, provider-reported reasoning token이 있으면 estimate를 emit하지 않도록 테스트했다.
- Grafana usage guide와 OpenAI-compatible surface spec에 estimated reasoning token의 관측용 성격과 billing/chargeback 제외 기준을 반영했다.
- 리뷰 중 stale comment 2곳과 축약된 review verification output을 비동작 보정했다.
## 최종 검증
- `GOCACHE=/tmp/iop-go-cache go test -count=1 ./apps/edge/internal/openai -run 'UsageMetrics|Reasoning'` - PASS; `ok iop/apps/edge/internal/openai 0.008s`
- `GOCACHE=/tmp/iop-go-cache go test -count=1 ./apps/edge/internal/openai -run 'OpenAI.*Usage|OpenAI.*Reasoning|ProviderTunnelPassthrough.*Usage|ResponsesProviderTunnelPassthrough.*Usage'` - PASS; `ok iop/apps/edge/internal/openai 0.011s`
- `GOCACHE=/tmp/iop-go-cache go test -count=1 ./apps/edge/internal/openai` - PASS; `ok iop/apps/edge/internal/openai 6.716s`
- `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` - PASS; source, docs, spec references present
- `git diff --check` - PASS; no output
- `git check-ignore -- agent-task/archive/2026/07/openai_reasoning_estimated_usage/01_metric_core/plan_local_G05_0.log agent-task/archive/2026/07/openai_reasoning_estimated_usage/01_metric_core/code_review_local_G05_0.log agent-task/archive/2026/07/openai_reasoning_estimated_usage/01_metric_core/complete.log` - PASS; no ignored artifact output
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -825,7 +825,7 @@ func (a *providerChatAssembler) recordProtoUsage(u *iop.Usage) {
}
// usageObservation returns the observed token usage plus the observed reasoning
// character count. Reasoning chars are never converted into a token estimate.
// character count used by auxiliary and estimated-token reasoning metrics.
func (a *providerChatAssembler) usageObservation() usageObservation {
obs := a.usage
obs.reasoningChars = a.reasoning.Len()

View file

@ -58,6 +58,10 @@ var (
"edge_id", "principal_ref", "principal_alias", "token_ref",
"model_group", "endpoint", "response_mode",
}
usageReasoningEstimateLabelNames = []string{
"edge_id", "principal_ref", "principal_alias", "token_ref",
"model_group", "endpoint", "response_mode", "estimation_method",
}
)
var (
@ -78,8 +82,13 @@ var (
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.",
Help: "Observed reasoning text characters for requests without provider-reported reasoning tokens. The token estimate is emitted separately via iop_openai_reasoning_estimated_tokens_total.",
}, usageReasoningLabelNames)
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)
)
// usageObservation is the metric-facing view of a request's token usage. Counts
@ -91,8 +100,8 @@ type usageObservation struct {
reasoningTokens int
cachedInputTokens int
// reasoningChars counts observed reasoning text characters. It feeds the
// reasoning-observed auxiliary metrics only and is never turned into a token
// estimate (SDD S07).
// reasoning-observed auxiliary metrics and the separate estimated-token
// counter when the provider does not report reasoning token usage.
reasoningChars int
}
@ -144,10 +153,20 @@ func (l usageLabels) reasoningValues() []string {
return []string{l.edgeID, l.principalRef, l.principalAlias, l.tokenRef, l.modelGroup, l.endpoint, l.responseMode}
}
// estimatedReasoningTokens converts observed reasoning text characters to a
// token estimate using the chars/4 rule of thumb. This is an operator-grade
// observation estimate only, never billing-grade. Caller reports a non-zero
// provider `reasoningTokens` value to skip this path entirely.
func estimatedReasoningTokens(chars int) int {
return (chars + 3) / 4
}
// emitUsageMetrics increments the request counter for the terminal status and,
// when usage was observed, the per-token-type counters. Reasoning text observed
// without a provider-reported reasoning token count only increments the
// auxiliary reasoning counters; it is never added to the token counter.
// without a provider-reported reasoning token count increments the auxiliary
// reasoning counters and emits a separate estimated-token counter; the
// provider-reported `token_type="reasoning"` counter stays exclusive to the
// provider's value.
func emitUsageMetrics(l usageLabels, status string, obs usageObservation) {
source := usageSourceUnavailable
if obs.hasTokens() {
@ -166,6 +185,11 @@ func emitUsageMetrics(l usageLabels, status string, obs usageObservation) {
if obs.reasoningChars > 0 && obs.reasoningTokens == 0 {
openAIReasoningObservedTotal.WithLabelValues(l.reasoningValues()...).Inc()
openAIReasoningCharsTotal.WithLabelValues(l.reasoningValues()...).Add(float64(obs.reasoningChars))
estimate := estimatedReasoningTokens(obs.reasoningChars)
openAIReasoningEstimatedTokensTotal.WithLabelValues(
l.edgeID, l.principalRef, l.principalAlias, l.tokenRef,
l.modelGroup, l.endpoint, l.responseMode, "chars_div_4",
).Add(float64(estimate))
}
}

View file

@ -42,6 +42,14 @@ func requestTokenValue(t *testing.T, l usageLabels, tokenType string) float64 {
))
}
func requestReasoningEstimateValue(t *testing.T, l usageLabels, method string) float64 {
t.Helper()
return testutil.ToFloat64(openAIReasoningEstimatedTokensTotal.WithLabelValues(
l.edgeID, l.principalRef, l.principalAlias, l.tokenRef,
l.modelGroup, l.endpoint, l.responseMode, method,
))
}
// TestOpenAIUsageMetricsLabelsExcludeSecretsAndHighCardinality asserts the label
// allowlists never carry raw tokens, prompt/response text, or per-request
// high-cardinality identifiers (SDD S08).
@ -55,7 +63,10 @@ func TestOpenAIUsageMetricsLabelsExcludeSecretsAndHighCardinality(t *testing.T)
}
// token_ref is an allowlisted stable alias, not a raw token; guard against a
// bare "token" label that could carry the secret itself.
for _, set := range [][]string{usageRequestLabelNames, usageTokenLabelNames, usageReasoningLabelNames} {
for _, set := range [][]string{
usageRequestLabelNames, usageTokenLabelNames,
usageReasoningLabelNames, usageReasoningEstimateLabelNames,
} {
for _, name := range set {
if name == "token" {
t.Fatalf("label %q may carry a raw token value; use token_ref", name)
@ -79,6 +90,15 @@ func TestOpenAIUsageMetricsLabelsExcludeSecretsAndHighCardinality(t *testing.T)
if !containsString(usageTokenLabelNames, "token_type") {
t.Fatalf("token labels must include token_type: %v", usageTokenLabelNames)
}
// estimated reasoning labels: only estimation_method on top of the base set.
if !containsString(usageReasoningEstimateLabelNames, "estimation_method") {
t.Fatalf("reasoning estimate labels must include estimation_method: %v", usageReasoningEstimateLabelNames)
}
for _, want := range []string{"edge_id", "principal_ref", "principal_alias", "token_ref", "model_group", "endpoint", "response_mode"} {
if !containsString(usageReasoningEstimateLabelNames, want) {
t.Fatalf("reasoning estimate labels missing %q", want)
}
}
}
// TestOpenAIUsageMetricsCountTokenTypes drives a normalized chat completion whose
@ -141,10 +161,12 @@ func TestOpenAIUsageMetricsCountTokenTypes(t *testing.T) {
}
}
// TestOpenAIReasoningObservedDoesNotEstimateTokens verifies that reasoning text
// observed without a provider-reported reasoning token count increments only the
// auxiliary reasoning counters and never the reasoning token counter (SDD S07).
func TestOpenAIReasoningObservedDoesNotEstimateTokens(t *testing.T) {
// TestOpenAIReasoningObservedEstimatesTokens verifies that reasoning text
// observed without a provider-reported reasoning token count increments the
// auxiliary reasoning counters and a separate estimated-token counter based on
// chars/4 rounding up, while leaving the provider-reported reasoning token
// counter untouched (SDD S07).
func TestOpenAIReasoningObservedEstimatesTokens(t *testing.T) {
const rawToken = "sk-reasoning-raw-token"
const edgeID = "edge-reasoning-observed"
const model = "reasoning-model"
@ -163,6 +185,7 @@ func TestOpenAIReasoningObservedDoesNotEstimateTokens(t *testing.T) {
modelGroup: model, endpoint: usageEndpointChatCompletions, responseMode: "normalized",
}
reasoningBefore := requestTokenValue(t, labels, tokenTypeReasoning)
estimatedBefore := requestReasoningEstimateValue(t, labels, "chars_div_4")
observedBefore := testutil.ToFloat64(openAIReasoningObservedTotal.WithLabelValues(labels.reasoningValues()...))
charsBefore := testutil.ToFloat64(openAIReasoningCharsTotal.WithLabelValues(labels.reasoningValues()...))
@ -178,7 +201,10 @@ func TestOpenAIReasoningObservedDoesNotEstimateTokens(t *testing.T) {
}
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)
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)
}
if got := testutil.ToFloat64(openAIReasoningObservedTotal.WithLabelValues(labels.reasoningValues()...)) - observedBefore; got != 1 {
t.Fatalf("reasoning_observed_total: got delta %v, want 1", got)
@ -188,6 +214,52 @@ func TestOpenAIReasoningObservedDoesNotEstimateTokens(t *testing.T) {
}
}
// TestOpenAIReasoningProviderReportedDoesNotEstimate verifies that when the
// provider reports reasoningTokens > 0 alongside observed reasoning text, the
// estimated-token counter stays untouched and the provider-reported reasoning
// counter advances by the reported count (SDD S07).
func TestOpenAIReasoningProviderReportedDoesNotEstimate(t *testing.T) {
const rawToken = "sk-reasoning-provider-token"
const edgeID = "edge-reasoning-provider"
const model = "reasoning-model"
const reasoning = "abcdef" // 6 chars; irrelevant for the no-estimate path
fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: reasoning}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "answer"}
fake.events <- &iop.RunEvent{Type: "complete", Usage: &iop.Usage{
InputTokens: 8, OutputTokens: 4, ReasoningTokens: 3,
}}
srv := NewServer(principalTokenCfg(rawToken, "ollama"), fake, nil)
srv.SetEdgeID(edgeID)
labels := usageLabels{
edgeID: edgeID, principalRef: "user:alice", principalAlias: "alice", tokenRef: "iop-tok-alice",
modelGroup: model, endpoint: usageEndpointChatCompletions, responseMode: "normalized",
}
reasoningBefore := requestTokenValue(t, labels, tokenTypeReasoning)
estimatedBefore := requestReasoningEstimateValue(t, labels, "chars_div_4")
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"reasoning-model",
"messages":[{"role":"user","content":"hi"}]
}`))
req.Header.Set("Authorization", "Bearer "+rawToken)
w := httptest.NewRecorder()
srv.routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
if got := requestTokenValue(t, labels, tokenTypeReasoning) - reasoningBefore; got != 3 {
t.Fatalf("provider-reported reasoning token: got delta %v, want 3", got)
}
if got := requestReasoningEstimateValue(t, labels, "chars_div_4") - estimatedBefore; got != 0 {
t.Fatalf("estimated reasoning must be zero when provider reports reasoning tokens: got delta %v", got)
}
}
// TestProviderTunnelPassthroughPreservesBodyAndObservesUsage verifies that a pure
// passthrough provider response is relayed byte-for-byte while its usage is
// observed into metrics (SDD S05/S06).

View file

@ -44,7 +44,11 @@ Same labels as `iop_openai_requests_total` excluding `status` and `usage_source`
### 4. `iop_openai_reasoning_chars_total` (Counter)
Observed reasoning text characters for requests without provider-reported reasoning tokens. Never converted to a token estimate.
Observed reasoning text characters for requests without provider-reported reasoning tokens. The token estimate is emitted separately via `iop_openai_reasoning_estimated_tokens_total`; provider-reported `token_type="reasoning"` remains authoritative.
### 5. `iop_openai_reasoning_estimated_tokens_total` (Counter)
Estimated reasoning tokens for requests with observed reasoning text but without provider-reported reasoning token usage. Calculated as `ceil(chars / 4)` with `estimation_method="chars_div_4"`. This is an operator-grade observation estimate only, never billing-grade or chargeback-confirmed.
---
@ -55,9 +59,11 @@ Observed reasoning text characters for requests without provider-reported reason
```
edge_id, principal_ref, principal_alias, token_ref,
model_group, endpoint, response_mode, status,
token_type, usage_source
token_type, usage_source, estimation_method
```
`estimation_method``iop_openai_reasoning_estimated_tokens_total` 전용 보조 라벨이며, 그 외 counter에는 사용되지 않습니다.
### 금지 label
- `request_id`, `session_id` — request-level 상세는 후속 ledger/Loki 축에서 다룬다.
@ -180,9 +186,12 @@ sum by (principal_alias) (iop_openai_reasoning_observed_total)
# reasoning text character 합계
sum by (principal_alias) (iop_openai_reasoning_chars_total)
# estimated reasoning tokens (provider가 token 수를 보고하지 않는 경우)
sum by (principal_alias, estimation_method) (iop_openai_reasoning_estimated_tokens_total)
```
> **주의**: `iop_openai_reasoning_observed_total` / `iop_openai_reasoning_chars_total`는 provider-reported reasoning token이 있을 경우 emit되지 않는다. 즉, 이 metric은 "reasoning text는 있으나 token count 보고를 안 하는 provider"만 커버한다. token 추정을 위해 chars를 사용할 수 있으나 이 문서는 추정 권유를 하지 않는다.
> **주의**: `iop_openai_reasoning_observed_total` / `iop_openai_reasoning_chars_total` / `iop_openai_reasoning_estimated_tokens_total`는 provider-reported reasoning token이 있을 경우 emit되지 않는다. 즉, 이 metric은 "reasoning text는 있으나 token count 보고를 안 하는 provider"만 커버한다. `iop_openai_reasoning_estimated_tokens_total``estimation_method="chars_div_4"`로 ceil(chars/4) 추정을 제공하지만 billing-grade 정산값이 아니다.
---
@ -197,6 +206,8 @@ sum by (principal_alias) (iop_openai_reasoning_chars_total)
| cloud-equivalent cost (stat/table) | token rollup query에 price scalar 또는 Grafana calculated field 적용 | Stat or Table |
| avoided-cost ROI summary (stat) | cloud-equivalent cost 합계, 필요 시 별도 infra cost field 차감 | Stat |
| reasoning 보조 지표 (singlestat) | `sum(iop_openai_reasoning_observed_total)` | Singlestat |
| estimated reasoning tokens (bar) | `sum by (estimation_method) (iop_openai_reasoning_estimated_tokens_total)` | Bar chart |
| estimated reasoning vs provider-reported (line) | `sum by (estimation_method) (iop_openai_reasoning_estimated_tokens_total)` vs `sum by (principal_alias) (iop_openai_usage_tokens_total{token_type="reasoning"})` | Time series |
> Grafana JSON dashboard import file은 본 scope에 포함되지 않는다. 위 PromQL을 바탕으로 패널을 수동 구성한다.
@ -272,6 +283,8 @@ baseline 예시. 아래 숫자는 문서용 예시이며 최신 cloud 가격이
`reasoning` token은 provider가 별도 단가를 제공하면 별도 baseline을 둔다. 별도 단가가 없으면 운영자가 `output`과 같은 단가로 볼지, report에서 `reasoning`을 제외할지 baseline에 명시한다.
> `iop_openai_reasoning_estimated_tokens_total`은 operator observation용 추정치이며, cloud-equivalent cost에 합산하거나 chargeback에 사용하지 않는다. billing-grade reasoning cost는 provider-reported `iop_openai_usage_tokens_total{token_type="reasoning"}`에 한한다.
---
## Cost Formula
@ -367,7 +380,7 @@ Grafana table 구성 예:
- 운영자가 관리하는 cloud price baseline 필드
- token type별 cloud-equivalent cost 산식과 query/report 예시
- avoided-cost 수준의 ROI summary 기준
- provider reasoning report 누락 시 `reasoning_observed_total` / `reasoning_chars_total`로 보조 확인
- provider reasoning report 누락 시 `reasoning_observed_total` / `reasoning_chars_total`로 보조 확인, `reasoning_estimated_tokens_total`로 chars/4 token 추정치 제공 (billing-grade 아님)
이 rollup 기준은 후속 enforce Milestone에서 Prometheus query를 그대로 재사용할 수 있도록 설계되었다.
@ -382,6 +395,7 @@ Grafana table 구성 예:
| `usage_source` | `provider_reported`, `unavailable` |
| `token_type` | `input`, `output`, `reasoning`, `cached_input` |
| `response_mode` | `passthrough`, `normalized` |
| `estimation_method` | `chars_div_4` (특히 `iop_openai_reasoning_estimated_tokens_total` 전용) |
---