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:
parent
79b361d7d9
commit
9b7b4e044f
8 changed files with 227 additions and 42 deletions
|
|
@ -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 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를 보존한다. |
|
| 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로 집계한다. |
|
| 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는 후속 범위다. |
|
| 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 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로 집계한다. |
|
| 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`가 들어갈 수 있다.
|
- run metadata에는 `openai_model`, `openai_stream`, `strict_output`, `estimated_input_tokens`, `context_class`가 들어갈 수 있다.
|
||||||
- provider tunnel metadata에는 routing context와 관측 후보가 들어갈 수 있으며, provider body에는 합쳐지지 않는다.
|
- provider tunnel metadata에는 routing context와 관측 후보가 들어갈 수 있으며, provider body에는 합쳐지지 않는다.
|
||||||
- Node complete event metadata의 `openai_tool_calls`와 `openai_text_tool_fallback`은 response tool call 복원에 쓰인다.
|
- 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 값만 사용한다.
|
- 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` 기준 분해를 함께 사용할 수 있다.
|
- `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에 넣지 않는다.
|
- `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한다.
|
- caller가 `metadata.iop_principal_*`를 보내도 authenticated context 값이 overwrite한다.
|
||||||
- `openai.principal_tokens[]` 변경은 restart-required로 분류된다.
|
- `openai.principal_tokens[]` 변경은 restart-required로 분류된다.
|
||||||
- principal token auth가 실패하면 legacy `openai.bearer_token`이 unmapped fallback으로 동작한다.
|
- 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가 아니다.
|
- 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 경계를 따른다.
|
- 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-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-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: 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 우선 기준을 반영.
|
||||||
|
|
|
||||||
|
|
@ -34,41 +34,43 @@ task=openai_reasoning_estimated_usage/01_metric_core, plan=0, tag=API
|
||||||
|
|
||||||
| 항목 | 완료 여부 |
|
| 항목 | 완료 여부 |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| [API-1] Reasoning estimated token metric 추가 | [ ] |
|
| [API-1] Reasoning estimated token metric 추가 | [x] |
|
||||||
| [API-2] Usage metric regression tests 정리 | [ ] |
|
| [API-2] Usage metric regression tests 정리 | [x] |
|
||||||
| [API-3] Grafana/spec 문서 갱신 | [ ] |
|
| [API-3] Grafana/spec 문서 갱신 | [x] |
|
||||||
|
|
||||||
## 구현 체크리스트
|
## 구현 체크리스트
|
||||||
|
|
||||||
- [ ] `apps/edge/internal/openai/usage_metrics.go`에 provider 미보고 reasoning text를 별도 estimated-token metric으로 emit하는 로직을 추가한다.
|
- [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을 추가/갱신한다.
|
- [x] `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의 차이를 문서화한다.
|
- [x] `docs/openai-usage-grafana.md`와 `agent-spec/input/openai-compatible-surface.md`에 reported reasoning token과 estimated reasoning token의 차이를 문서화한다.
|
||||||
- [ ] 최종 검증 명령을 모두 실행하고 예상 결과와 실제 출력 차이를 review stub에 기록한다.
|
- [x] 최종 검증 명령을 모두 실행하고 예상 결과와 실제 출력 차이를 review stub에 기록한다.
|
||||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||||
|
|
||||||
## 코드리뷰 전용 체크리스트
|
## 코드리뷰 전용 체크리스트
|
||||||
|
|
||||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||||
|
|
||||||
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||||
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||||
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G05_0.log`로 아카이브한다.
|
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G05_0.log`로 아카이브한다.
|
||||||
- [ ] active `PLAN-*-G??.md`를 `plan_local_G05_0.log`로 아카이브한다.
|
- [x] 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하는지 확인한다.
|
- [x] `.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` 파일을 남기지 않는다.
|
- [x] 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 경로에서 이 체크리스트를 갱신한다.
|
- [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 경로에서 이 체크리스트를 갱신한다.
|
||||||
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/openai_reasoning_estimated_usage/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
- [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`를 작성하지 않는다.
|
- [ ] 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`를 남기지 않는다.
|
- [ ] 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 중간 검증
|
### API-1 중간 검증
|
||||||
```bash
|
```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 'UsageMetrics|Reasoning'
|
||||||
(output)
|
ok iop/apps/edge/internal/openai 0.008s
|
||||||
```
|
```
|
||||||
|
|
||||||
### API-2 중간 검증
|
### API-2 중간 검증
|
||||||
```bash
|
```bash
|
||||||
$ 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 -run 'OpenAI.*Usage|OpenAI.*Reasoning|ProviderTunnelPassthrough.*Usage|ResponsesProviderTunnelPassthrough.*Usage'
|
||||||
(output)
|
ok iop/apps/edge/internal/openai 0.011s
|
||||||
```
|
```
|
||||||
|
|
||||||
### API-3 중간 검증
|
### API-3 중간 검증
|
||||||
```bash
|
```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
|
$ 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
|
```bash
|
||||||
$ GOCACHE=/tmp/iop-go-cache go test -count=1 ./apps/edge/internal/openai
|
$ 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
|
$ git diff --check
|
||||||
(output)
|
|
||||||
```
|
```
|
||||||
|
(No output — 모든 코드 변경이 유효합니다.)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -145,3 +161,21 @@ $ git diff --check
|
||||||
| 사용자 리뷰 요청 | Implementing agent | Keep 없음 unless selected Milestone lock blocks implementation |
|
| 사용자 리뷰 요청 | Implementing agent | Keep 없음 unless selected Milestone lock blocks implementation |
|
||||||
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus |
|
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus |
|
||||||
| 검증 결과 | Implementing agent | Paste actual stdout/stderr |
|
| 검증 결과 | 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` 검증 출력.
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
## 후속 작업
|
||||||
|
|
||||||
|
- 없음
|
||||||
|
|
@ -825,7 +825,7 @@ func (a *providerChatAssembler) recordProtoUsage(u *iop.Usage) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// usageObservation returns the observed token usage plus the observed reasoning
|
// 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 {
|
func (a *providerChatAssembler) usageObservation() usageObservation {
|
||||||
obs := a.usage
|
obs := a.usage
|
||||||
obs.reasoningChars = a.reasoning.Len()
|
obs.reasoningChars = a.reasoning.Len()
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,10 @@ var (
|
||||||
"edge_id", "principal_ref", "principal_alias", "token_ref",
|
"edge_id", "principal_ref", "principal_alias", "token_ref",
|
||||||
"model_group", "endpoint", "response_mode",
|
"model_group", "endpoint", "response_mode",
|
||||||
}
|
}
|
||||||
|
usageReasoningEstimateLabelNames = []string{
|
||||||
|
"edge_id", "principal_ref", "principal_alias", "token_ref",
|
||||||
|
"model_group", "endpoint", "response_mode", "estimation_method",
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -78,8 +82,13 @@ var (
|
||||||
|
|
||||||
openAIReasoningCharsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
openAIReasoningCharsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||||
Name: "iop_openai_reasoning_chars_total",
|
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)
|
}, 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
|
// usageObservation is the metric-facing view of a request's token usage. Counts
|
||||||
|
|
@ -91,8 +100,8 @@ type usageObservation struct {
|
||||||
reasoningTokens int
|
reasoningTokens int
|
||||||
cachedInputTokens int
|
cachedInputTokens int
|
||||||
// reasoningChars counts observed reasoning text characters. It feeds the
|
// reasoningChars counts observed reasoning text characters. It feeds the
|
||||||
// reasoning-observed auxiliary metrics only and is never turned into a token
|
// reasoning-observed auxiliary metrics and the separate estimated-token
|
||||||
// estimate (SDD S07).
|
// counter when the provider does not report reasoning token usage.
|
||||||
reasoningChars int
|
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}
|
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,
|
// emitUsageMetrics increments the request counter for the terminal status and,
|
||||||
// when usage was observed, the per-token-type counters. Reasoning text observed
|
// when usage was observed, the per-token-type counters. Reasoning text observed
|
||||||
// without a provider-reported reasoning token count only increments the
|
// without a provider-reported reasoning token count increments the auxiliary
|
||||||
// auxiliary reasoning counters; it is never added to the token counter.
|
// 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) {
|
func emitUsageMetrics(l usageLabels, status string, obs usageObservation) {
|
||||||
source := usageSourceUnavailable
|
source := usageSourceUnavailable
|
||||||
if obs.hasTokens() {
|
if obs.hasTokens() {
|
||||||
|
|
@ -166,6 +185,11 @@ func emitUsageMetrics(l usageLabels, status string, obs usageObservation) {
|
||||||
if obs.reasoningChars > 0 && obs.reasoningTokens == 0 {
|
if obs.reasoningChars > 0 && obs.reasoningTokens == 0 {
|
||||||
openAIReasoningObservedTotal.WithLabelValues(l.reasoningValues()...).Inc()
|
openAIReasoningObservedTotal.WithLabelValues(l.reasoningValues()...).Inc()
|
||||||
openAIReasoningCharsTotal.WithLabelValues(l.reasoningValues()...).Add(float64(obs.reasoningChars))
|
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
// TestOpenAIUsageMetricsLabelsExcludeSecretsAndHighCardinality asserts the label
|
||||||
// allowlists never carry raw tokens, prompt/response text, or per-request
|
// allowlists never carry raw tokens, prompt/response text, or per-request
|
||||||
// high-cardinality identifiers (SDD S08).
|
// 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
|
// token_ref is an allowlisted stable alias, not a raw token; guard against a
|
||||||
// bare "token" label that could carry the secret itself.
|
// 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 {
|
for _, name := range set {
|
||||||
if name == "token" {
|
if name == "token" {
|
||||||
t.Fatalf("label %q may carry a raw token value; use token_ref", name)
|
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") {
|
if !containsString(usageTokenLabelNames, "token_type") {
|
||||||
t.Fatalf("token labels must include token_type: %v", usageTokenLabelNames)
|
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
|
// TestOpenAIUsageMetricsCountTokenTypes drives a normalized chat completion whose
|
||||||
|
|
@ -141,10 +161,12 @@ func TestOpenAIUsageMetricsCountTokenTypes(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestOpenAIReasoningObservedDoesNotEstimateTokens verifies that reasoning text
|
// TestOpenAIReasoningObservedEstimatesTokens verifies that reasoning text
|
||||||
// observed without a provider-reported reasoning token count increments only the
|
// observed without a provider-reported reasoning token count increments the
|
||||||
// auxiliary reasoning counters and never the reasoning token counter (SDD S07).
|
// auxiliary reasoning counters and a separate estimated-token counter based on
|
||||||
func TestOpenAIReasoningObservedDoesNotEstimateTokens(t *testing.T) {
|
// 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 rawToken = "sk-reasoning-raw-token"
|
||||||
const edgeID = "edge-reasoning-observed"
|
const edgeID = "edge-reasoning-observed"
|
||||||
const model = "reasoning-model"
|
const model = "reasoning-model"
|
||||||
|
|
@ -163,6 +185,7 @@ func TestOpenAIReasoningObservedDoesNotEstimateTokens(t *testing.T) {
|
||||||
modelGroup: model, endpoint: usageEndpointChatCompletions, responseMode: "normalized",
|
modelGroup: model, endpoint: usageEndpointChatCompletions, responseMode: "normalized",
|
||||||
}
|
}
|
||||||
reasoningBefore := requestTokenValue(t, labels, tokenTypeReasoning)
|
reasoningBefore := requestTokenValue(t, labels, tokenTypeReasoning)
|
||||||
|
estimatedBefore := requestReasoningEstimateValue(t, labels, "chars_div_4")
|
||||||
observedBefore := testutil.ToFloat64(openAIReasoningObservedTotal.WithLabelValues(labels.reasoningValues()...))
|
observedBefore := testutil.ToFloat64(openAIReasoningObservedTotal.WithLabelValues(labels.reasoningValues()...))
|
||||||
charsBefore := testutil.ToFloat64(openAIReasoningCharsTotal.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 {
|
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 {
|
if got := testutil.ToFloat64(openAIReasoningObservedTotal.WithLabelValues(labels.reasoningValues()...)) - observedBefore; got != 1 {
|
||||||
t.Fatalf("reasoning_observed_total: got delta %v, want 1", got)
|
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
|
// TestProviderTunnelPassthroughPreservesBodyAndObservesUsage verifies that a pure
|
||||||
// passthrough provider response is relayed byte-for-byte while its usage is
|
// passthrough provider response is relayed byte-for-byte while its usage is
|
||||||
// observed into metrics (SDD S05/S06).
|
// observed into metrics (SDD S05/S06).
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,11 @@ Same labels as `iop_openai_requests_total` excluding `status` and `usage_source`
|
||||||
|
|
||||||
### 4. `iop_openai_reasoning_chars_total` (Counter)
|
### 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,
|
edge_id, principal_ref, principal_alias, token_ref,
|
||||||
model_group, endpoint, response_mode, status,
|
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
|
### 금지 label
|
||||||
|
|
||||||
- `request_id`, `session_id` — request-level 상세는 후속 ledger/Loki 축에서 다룬다.
|
- `request_id`, `session_id` — request-level 상세는 후속 ledger/Loki 축에서 다룬다.
|
||||||
|
|
@ -180,9 +186,12 @@ sum by (principal_alias) (iop_openai_reasoning_observed_total)
|
||||||
|
|
||||||
# reasoning text character 합계
|
# reasoning text character 합계
|
||||||
sum by (principal_alias) (iop_openai_reasoning_chars_total)
|
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 |
|
| 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 |
|
| avoided-cost ROI summary (stat) | cloud-equivalent cost 합계, 필요 시 별도 infra cost field 차감 | Stat |
|
||||||
| reasoning 보조 지표 (singlestat) | `sum(iop_openai_reasoning_observed_total)` | Singlestat |
|
| 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을 바탕으로 패널을 수동 구성한다.
|
> Grafana JSON dashboard import file은 본 scope에 포함되지 않는다. 위 PromQL을 바탕으로 패널을 수동 구성한다.
|
||||||
|
|
||||||
|
|
@ -272,6 +283,8 @@ baseline 예시. 아래 숫자는 문서용 예시이며 최신 cloud 가격이
|
||||||
|
|
||||||
`reasoning` token은 provider가 별도 단가를 제공하면 별도 baseline을 둔다. 별도 단가가 없으면 운영자가 `output`과 같은 단가로 볼지, report에서 `reasoning`을 제외할지 baseline에 명시한다.
|
`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
|
## Cost Formula
|
||||||
|
|
@ -367,7 +380,7 @@ Grafana table 구성 예:
|
||||||
- 운영자가 관리하는 cloud price baseline 필드
|
- 운영자가 관리하는 cloud price baseline 필드
|
||||||
- token type별 cloud-equivalent cost 산식과 query/report 예시
|
- token type별 cloud-equivalent cost 산식과 query/report 예시
|
||||||
- avoided-cost 수준의 ROI summary 기준
|
- 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를 그대로 재사용할 수 있도록 설계되었다.
|
이 rollup 기준은 후속 enforce Milestone에서 Prometheus query를 그대로 재사용할 수 있도록 설계되었다.
|
||||||
|
|
||||||
|
|
@ -382,6 +395,7 @@ Grafana table 구성 예:
|
||||||
| `usage_source` | `provider_reported`, `unavailable` |
|
| `usage_source` | `provider_reported`, `unavailable` |
|
||||||
| `token_type` | `input`, `output`, `reasoning`, `cached_input` |
|
| `token_type` | `input`, `output`, `reasoning`, `cached_input` |
|
||||||
| `response_mode` | `passthrough`, `normalized` |
|
| `response_mode` | `passthrough`, `normalized` |
|
||||||
|
| `estimation_method` | `chars_div_4` (특히 `iop_openai_reasoning_estimated_tokens_total` 전용) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue