From 9b7b4e044ff156f59d395c53975812eaf17081d7 Mon Sep 17 00:00:00 2001 From: toki Date: Tue, 14 Jul 2026 18:57:43 +0900 Subject: [PATCH] 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 --- agent-spec/input/openai-compatible-surface.md | 7 +- .../code_review_local_G05_0.log} | 80 +++++++++++++----- .../01_metric_core/complete.log | 40 +++++++++ .../01_metric_core/plan_local_G05_0.log} | 0 apps/edge/internal/openai/stream.go | 2 +- apps/edge/internal/openai/usage_metrics.go | 34 ++++++-- .../internal/openai/usage_metrics_test.go | 84 +++++++++++++++++-- docs/openai-usage-grafana.md | 22 ++++- 8 files changed, 227 insertions(+), 42 deletions(-) rename agent-task/{openai_reasoning_estimated_usage/01_metric_core/CODE_REVIEW-local-G05.md => archive/2026/07/openai_reasoning_estimated_usage/01_metric_core/code_review_local_G05_0.log} (56%) create mode 100644 agent-task/archive/2026/07/openai_reasoning_estimated_usage/01_metric_core/complete.log rename agent-task/{openai_reasoning_estimated_usage/01_metric_core/PLAN-local-G05.md => archive/2026/07/openai_reasoning_estimated_usage/01_metric_core/plan_local_G05_0.log} (100%) diff --git a/agent-spec/input/openai-compatible-surface.md b/agent-spec/input/openai-compatible-surface.md index 48a8319..1eba4db 100644 --- a/agent-spec/input/openai-compatible-surface.md +++ b/agent-spec/input/openai-compatible-surface.md @@ -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 우선 기준을 반영. diff --git a/agent-task/openai_reasoning_estimated_usage/01_metric_core/CODE_REVIEW-local-G05.md b/agent-task/archive/2026/07/openai_reasoning_estimated_usage/01_metric_core/code_review_local_G05_0.log similarity index 56% rename from agent-task/openai_reasoning_estimated_usage/01_metric_core/CODE_REVIEW-local-G05.md rename to agent-task/archive/2026/07/openai_reasoning_estimated_usage/01_metric_core/code_review_local_G05_0.log index 882991f..6f4d5fc 100644 --- a/agent-task/openai_reasoning_estimated_usage/01_metric_core/CODE_REVIEW-local-G05.md +++ b/agent-task/archive/2026/07/openai_reasoning_estimated_usage/01_metric_core/code_review_local_G05_0.log @@ -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` 검증 출력. diff --git a/agent-task/archive/2026/07/openai_reasoning_estimated_usage/01_metric_core/complete.log b/agent-task/archive/2026/07/openai_reasoning_estimated_usage/01_metric_core/complete.log new file mode 100644 index 0000000..6f9afc5 --- /dev/null +++ b/agent-task/archive/2026/07/openai_reasoning_estimated_usage/01_metric_core/complete.log @@ -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 + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/openai_reasoning_estimated_usage/01_metric_core/PLAN-local-G05.md b/agent-task/archive/2026/07/openai_reasoning_estimated_usage/01_metric_core/plan_local_G05_0.log similarity index 100% rename from agent-task/openai_reasoning_estimated_usage/01_metric_core/PLAN-local-G05.md rename to agent-task/archive/2026/07/openai_reasoning_estimated_usage/01_metric_core/plan_local_G05_0.log diff --git a/apps/edge/internal/openai/stream.go b/apps/edge/internal/openai/stream.go index eb3e157..f36b3f5 100644 --- a/apps/edge/internal/openai/stream.go +++ b/apps/edge/internal/openai/stream.go @@ -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() diff --git a/apps/edge/internal/openai/usage_metrics.go b/apps/edge/internal/openai/usage_metrics.go index 9ce726b..aa499b0 100644 --- a/apps/edge/internal/openai/usage_metrics.go +++ b/apps/edge/internal/openai/usage_metrics.go @@ -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)) } } diff --git a/apps/edge/internal/openai/usage_metrics_test.go b/apps/edge/internal/openai/usage_metrics_test.go index 3e5de99..08e3900 100644 --- a/apps/edge/internal/openai/usage_metrics_test.go +++ b/apps/edge/internal/openai/usage_metrics_test.go @@ -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). diff --git a/docs/openai-usage-grafana.md b/docs/openai-usage-grafana.md index 0362b6f..7f7a74f 100644 --- a/docs/openai-usage-grafana.md +++ b/docs/openai-usage-grafana.md @@ -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` 전용) | ---