<!-- task=m-provider-usage-attribution-hot-path/02+01_attempt_usage_emission plan=0 tag=API -->

# Actual Provider Attempt Usage Emission

## For the Implementing Agent

Filling implementation-owned sections in `CODE_REVIEW-cloud-G10.md` is mandatory. Run every verification command, paste actual notes/output, leave the active pair in place, and report ready for review. Finalization belongs only to the code-review skill. If blocked, record the exact blocker, attempted commands/output, and resume condition in implementation-owned evidence fields; do not ask the user, call user-input tools, create stop files, classify the next state, archive logs, or write `complete.log`.

## Background

The current emitter couples token usage and the HTTP terminal counter in one call and retains only the final recovery attempt's usage. This packet consumes the validated dispatch binding foundation and records provider-reported usage once per actual attempt while preserving exactly one request terminal count.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/operational-observability-provider-management/milestones/provider-usage-attribution-hot-path.md`
- Milestone link: [Milestone 문서](agent-roadmap/phase/operational-observability-provider-management/milestones/provider-usage-attribution-hot-path.md)
- Task ids:
  - `group-policy`: approved model-group rollup policy over one canonical provider series
  - `dispatch-binding`: actual provider, served model, and node binding reaches the emitter
  - `attempt-usage`: retry/fallback usage is emitted once per actual provider attempt
- Completion mode: check-on-pass

## Analysis

### Files Read

- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/rules/common/rules-agent-spec.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/platform-common/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/profiles/edge-smoke.md`
- `agent-test/local/profiles/platform-common-smoke.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/operational-observability-provider-management/PHASE.md`
- `agent-roadmap/phase/operational-observability-provider-management/milestones/provider-usage-attribution-hot-path.md`
- `agent-roadmap/sdd/operational-observability-provider-management/provider-usage-attribution-hot-path/SDD.md`
- `agent-contract/index.md`
- `agent-contract/inner/edge-config-runtime-refresh.md`
- `agent-contract/outer/openai-compatible-api.md`
- `agent-spec/index.md`
- `agent-spec/input/openai-compatible-surface.md`
- `apps/edge/internal/openai/usage_metrics.go`
- `apps/edge/internal/openai/dispatch_context.go`
- `apps/edge/internal/openai/stream_gate_dispatcher.go`
- `apps/edge/internal/openai/stream_gate_runtime.go`
- `apps/edge/internal/openai/responses_stream_gate.go`
- `apps/edge/internal/openai/chat_handler.go`
- `apps/edge/internal/openai/chat_completion.go`
- `apps/edge/internal/openai/buffered_sse.go`
- `apps/edge/internal/openai/normalized_sse.go`
- `apps/edge/internal/openai/chat_stream_session.go`
- `apps/edge/internal/openai/provider_tunnel.go`
- `apps/edge/internal/openai/provider_observation.go`
- `apps/edge/internal/openai/responses_handler.go`
- `apps/edge/internal/openai/responses_completion.go`
- `apps/edge/internal/openai/run_result.go`
- `apps/edge/internal/openai/usage_metrics_test.go`
- `apps/edge/internal/openai/stream_gate_dispatcher_test.go`
- `apps/edge/internal/openai/server_test_support_test.go`
- `apps/edge/internal/openai/provider_test_support_test.go`
- `apps/edge/internal/service/run_types.go`
- `scripts/e2e-provider-capacity-smoke.sh`
- `Makefile`

### SDD Criteria

The approved, unlocked SDD is `agent-roadmap/sdd/operational-observability-provider-management/provider-usage-attribution-hot-path/SDD.md`. This packet targets S01/`group-policy`, S02/`dispatch-binding`, and S03/`attempt-usage`. Evidence Map S01 requires config/route policy plus provider-series/group-rollup delta assertions; S02 requires direct, pool-normalized, and pool-tunnel actual binding assertions; S03 requires a deterministic provider switch with per-provider token deltas and request counter delta one. API-1 maps S01/S02 to the emitter contract, API-2 maps all attempt paths to S03, and API-3 supplies the exact Evidence Map tests and final verification.

### Verification Context

No handoff was supplied. Verification sources actually read were `agent-test/local/rules.md`, `agent-test/local/profiles/edge-smoke.md`, `agent-test/local/profiles/platform-common-smoke.md`, `Makefile`, and `scripts/e2e-provider-capacity-smoke.sh`. Preflight on branch `provider-usage-attribution-hot-path` at HEAD `88f518f0294d461d5e05b9a14229fed3d6126d12` found a clean checkout, Go `go1.26.2 linux/arm64`, module `/config/workspace/iop-s1/go.mod`, and `go`, `curl`, and `jq` available. The inherited `GOCACHE` is unreadable; `/tmp/iop-s1-provider-usage-attribution-gocache` produced fresh passing baselines for config, service, and OpenAI packages. The repository-native e2e smoke uses local temporary processes and ports and needs no external credential. Cached output is not acceptable.

### Test Coverage Gaps

- `emitUsageMetrics` increments request and token counters together, so it cannot represent two provider attempts and one request terminal.
- `openAIStreamGateUsageHolder` intentionally stores only the latest successful attempt and discards aborted-attempt usage.
- Runtime-enabled Chat and Responses recovery event-source factories do not bind each observation to that attempt's `RunDispatch`.
- Legacy tool-validation retry loops close failed attempts without emitting any provider-reported usage they returned.
- Existing metric tests assert request-model `model_group`; none asserts actual provider/served-model separation, approved group rollup basis, or a provider switch.
- Existing tests do not cover a retry that changes execution path/response mode, so attempt labels could accidentally inherit the final request mode.

### Symbol References

- `emitUsageMetrics` call sites exist in `chat_handler.go`, `chat_completion.go`, `buffered_sse.go`, `chat_stream_session.go`, `provider_tunnel.go`, `responses_handler.go`, `responses_completion.go`, `stream_gate_runtime.go`, and `responses_stream_gate.go`; every call must move to explicit request-terminal or attempt-usage ownership.
- `usageLabelsFor`, `responsesDispatchContext.usageLabels`, and `chatDispatchContext.usageLabels` are the request-model label constructors and must be replaced or narrowed consistently.
- `openAIStreamGateUsageHolder` is constructed and consumed in Chat, Responses, and tunnel runtime builders; preserve response rendering's final-attempt observation separately from metric attempt accumulation.
- `openAIAttemptTransport` and `openAIAttemptController` are created for initial and recovery attempts in `stream_gate_dispatcher.go`, `stream_gate_runtime.go`, and `responses_stream_gate.go`.

### Split Judgment

- `01_attribution_binding_foundation`: predecessor stable contract is validated `ProviderID`, `Target`, `NodeID`, and `UsageAttribution` on every dispatch.
- `02+01_attempt_usage_emission`: stable contract is one provider-attributed token/reasoning emission per observed attempt plus one request terminal emission.

Dependency `01_attribution_binding_foundation` is currently missing its active `complete.log`; do not implement this packet until the task-loop dependency resolver confirms `agent-task/m-provider-usage-attribution-hot-path/01_attribution_binding_foundation/complete.log` or supplies the exact matching archived predecessor `complete.log`. Do not search `agent-task/archive/**` broadly.

### Scope Rationale

Do not change provider routing/admission, retry selection, recovery limits, response body framing, token estimation, metric names, Grafana queries, pricing, request ledgers, or billing. S04 `metric-contract` and S05 `grafana-migration` remain unchecked; this packet updates only the minimum current contract/spec text required by S01-S03 code semantics.

### Final Routing

- evaluation_mode: `first-pass`
- finalizer: `finalize-task-policy.sh pair`
- build: all closures true; scores `scope=2,state=2,blast=2,evidence=2,verification=2`; grade `G10`, route `cloud`, basis `grade-boundary`, filename `PLAN-cloud-G10.md`
- review: all closures true; scores `scope=2,state=2,blast=2,evidence=2,verification=2`; grade `G10`, route `cloud`, basis `official-review`, filename `CODE_REVIEW-cloud-G10.md`
- large_indivisible_context: `false`
- positive loop risks: `temporal_state`, `concurrent_consistency`, `boundary_contract`, `variant_product` (count `4`)
- recovery signals: review rework `0`, evidence-integrity failure `false`
- capability-gap evidence: none

## Implementation Checklist

- [ ] Split request-terminal accounting from provider-attributed attempt usage and enforce the approved group policy without duplicate counters.
- [ ] Bind and finalize usage for every direct, tunnel, normalized, retry, fallback, success, error, and cancel attempt exactly once.
- [ ] Add deterministic S01-S03 tests and synchronize the minimum current OpenAI contract/spec.
- [ ] Run fresh focused, full-package, race, local provider-capacity smoke, formatting, and diff verification.
- [ ] Fill implementation-owned sections in CODE_REVIEW-*-G??.md with actual implementation notes and verification output.

### [API-1] Separate Request and Attempt Metric Contracts

#### Problem

`apps/edge/internal/openai/usage_metrics.go:164` makes `emitUsageMetrics` increment both `iop_openai_requests_total` and token/reasoning counters using one request-model label set. This cannot preserve SDD rules that canonical usage starts at actual provider binding, approved groups roll up only at query time, and the HTTP terminal counter increments once.

#### Solution

Replace the combined emitter with a request-local recorder that owns two distinct operations:

1. `RecordAttempt(binding, observation)` adds provider-reported token/reasoning values once for one dispatch binding.
2. `FinishRequest(status, responseMode)` increments the request terminal counter once at the final committed mode and derives `usage_source` from whether any attempt reported the existing provider token fields.

Use separate low-cardinality label structs. Request labels keep caller identity, `route_model`, endpoint, the final committed response mode, status, and usage source. Each attempt derives its response mode from that attempt's actual execution path and binds `usage_attribution`, actual `provider_id`, `served_model`, and `node_id`. Carry `node_id` through the immutable recorder binding and cover it with unit assertions, but do not add it to a public metric label vector in this packet; the public token/reasoning vectors use only the approved stable subset. Use an internal attempt/run identity only for exactly-once state and never expose it as a label. `usage_attribution=model_group` enables only query-time grouping by `route_model`; it never emits a second token counter. Preserve the existing `usage_source` rule: auxiliary reasoning-character observations alone do not reclassify a request as provider-reported.

```go
// Before (apps/edge/internal/openai/usage_metrics.go:170)
func emitUsageMetrics(l usageLabels, status string, obs usageObservation) {
    openAIRequestsTotal.WithLabelValues(...).Inc()
    addTokenCount(l, tokenTypeInput, obs.inputTokens)
}

// After
type openAIUsageRecorder struct {
    // request terminal once + per-attempt deduplication state
}

func (r *openAIUsageRecorder) RecordAttempt(binding usageDispatchBinding, obs usageObservation)
func (r *openAIUsageRecorder) FinishRequest(status, responseMode string)
```

#### Modified Files and Checklist

- [ ] `apps/edge/internal/openai/usage_metrics.go` — define split labels, dispatch binding, request recorder, per-attempt dedupe, and exactly-once terminal logic.
- [ ] `apps/edge/internal/openai/dispatch_context.go` — create one recorder at ingress and derive actual attempt labels from `RunDispatch`.
- [ ] `apps/edge/internal/openai/usage_metrics_test.go` — update allowlist/helpers and cover default/group/direct/hybrid bases with no duplicate group counter.

#### Test Strategy

Extend `apps/edge/internal/openai/usage_metrics_test.go` with `TestUsageAttributionPolicyEmitsSingleCanonicalProviderSeries`, `TestUsageRecorderFinishesRequestOnce`, and updated secret/high-cardinality guards. Assert default `provider`, approved `model_group`, actual provider/served model, internal node preservation, no public `node_id` or attempt/run/session label, no adapter substitution, no second group counter, duplicate attempt finalization ignored, final request response mode preserved, and terminal request delta one.

#### Verification

```bash
mkdir -p /tmp/iop-s1-provider-usage-attribution-gocache
GOCACHE=/tmp/iop-s1-provider-usage-attribution-gocache go test -count=1 ./apps/edge/internal/openai -run 'UsageAttributionPolicy|UsageRecorder|UsageMetricsLabels'
```

Expected: policy/label/dedupe tests pass and one usage observation changes exactly one canonical provider series.

### [API-2] Finalize Usage at Every Attempt Lifecycle Boundary

#### Problem

`apps/edge/internal/openai/stream_gate_runtime.go:83` retains only last-attempt usage, while `apps/edge/internal/openai/stream_gate_dispatcher.go:296` closes or aborts an attempt without metric finalization. Legacy retries in `apps/edge/internal/openai/chat_completion.go:72` and `apps/edge/internal/openai/buffered_sse.go:55` also close the failed attempt before recording its returned usage.

#### Solution

Carry the actual `RunDispatch` and that attempt's execution/response mode on each `openAIAttemptTransport`, then create an attempt observation owned jointly by its event source and idempotent controller. Event sources record provider-reported usage/reasoning; `CloseAttempt` and `AbortAttempt` claim and flush observed values exactly once before releasing transport ownership, while an aborted attempt with no observation emits no token/reasoning delta. Keep the existing final-attempt holder only for response body rendering. Update non-runtime normalized, live SSE, buffered, Responses, and provider-tunnel paths to use the same recorder contract. In legacy tool-validation retries, record usage returned by the rejected attempt before starting the replacement. A dispatch failure with no binding emits only the request terminal; an attempt with reported usage emits that usage even when recovery replaces it. All terminal paths call `FinishRequest`, but its once guard ensures one HTTP request count and the terminal request uses the final committed response mode rather than overwriting prior attempt modes.

```go
// Before (apps/edge/internal/openai/stream_gate_dispatcher.go:296)
type openAIAttemptController struct {
    dispatch       edgeservice.RunDispatch
    closeTransport func()
}

// After
type openAIAttemptController struct {
    dispatch       edgeservice.RunDispatch
    observation    *openAIAttemptUsage
    closeTransport func()
}
```

```go
// Before (apps/edge/internal/openai/stream_gate_runtime.go:83)
// openAIStreamGateUsageHolder carries the last-attempt usage observation...

// After
// The response holder remains final-attempt-only; metric observations are
// finalized by each attempt controller against its immutable dispatch binding.
```

#### Modified Files and Checklist

- [ ] `apps/edge/internal/openai/stream_gate_dispatcher.go` — attach dispatch and attempt usage to initial/recovery controllers.
- [ ] `apps/edge/internal/openai/stream_gate_runtime.go` — record normalized/buffered/tunnel observations per attempt and separate response usage.
- [ ] `apps/edge/internal/openai/responses_stream_gate.go` — bind Responses normalized/tunnel attempts and finish the request once.
- [ ] `apps/edge/internal/openai/chat_handler.go` — own the request recorder for dispatch failures and selected pool paths.
- [ ] `apps/edge/internal/openai/chat_completion.go` — emit each legacy non-stream attempt before retry/return.
- [ ] `apps/edge/internal/openai/buffered_sse.go` — emit each legacy buffered attempt before retry/return.
- [ ] `apps/edge/internal/openai/chat_stream_session.go` — bind live SSE terminal usage to the actual dispatch.
- [ ] `apps/edge/internal/openai/provider_tunnel.go` — bind direct/pool tunnel observations and terminal request status.
- [ ] `apps/edge/internal/openai/responses_handler.go` — own the Responses request recorder across direct/pool branches.
- [ ] `apps/edge/internal/openai/responses_completion.go` — record normalized Responses attempt usage separately from request terminal.
- [ ] `apps/edge/internal/openai/provider_observation.go` — expose one immutable attempt observation without changing provider bytes.

#### Test Strategy

Add `apps/edge/internal/openai/usage_attribution_attempt_test.go` with `TestProviderSwitchEmitsUsagePerActualAttemptAndOneRequest`. Use deterministic in-memory provider A/provider B dispatches that report different input/output/reasoning/cached counts, force one recovery switch across distinct execution/response modes, and assert independent provider-and-mode series deltas plus request delta one at the final committed mode. Extend `apps/edge/internal/openai/stream_gate_dispatcher_test.go` to prove Close/Abort finalization idempotence, observed usage on an aborted/replaced attempt, zero usage for an unobserved aborted attempt and pre-dispatch failure, and no adapter fallback.

#### Verification

```bash
GOCACHE=/tmp/iop-s1-provider-usage-attribution-gocache go test -count=1 ./apps/edge/internal/openai -run 'ProviderSwitchEmitsUsage|Attempt.*Usage|ActualOpenAIProvider'
GOCACHE=/tmp/iop-s1-provider-usage-attribution-gocache go test -race -count=1 ./apps/edge/internal/openai -run 'ProviderSwitchEmitsUsage|Attempt.*Usage'
```

Expected: provider A and B series receive only their own counts, request terminal delta is one, and race/deduplication checks pass.

### [API-3] Lock S01-S03 Evidence and Current Contract

#### Problem

`agent-contract/outer/openai-compatible-api.md:174` and `agent-spec/input/openai-compatible-surface.md:157` still describe request-alias `model_group` attribution. Without synchronized current semantics and deterministic scenario names, code review cannot map SDD Evidence Map S01-S03 to completion.

#### Solution

Update the current OpenAI contract/spec only for the implemented S01-S03 semantics: `route_model`, actual provider/served model, policy default/opt-in, one canonical series, per-attempt execution mode, and one request terminal at the final committed mode. Record that node identity reaches the recorder binding but is not introduced as a public metric label here. Explicitly leave Grafana query migration and the full S04 label-contract closure to the second epic. Keep raw/high-cardinality prohibitions unchanged.

#### Modified Files and Checklist

- [ ] `agent-contract/outer/openai-compatible-api.md` — record S01-S03 attribution and attempt/request cardinality contract.
- [ ] `agent-spec/input/openai-compatible-surface.md` — synchronize current dispatch/metric behavior and test pointers.
- [ ] `apps/edge/internal/openai/usage_attribution_attempt_test.go` — provide named S03 provider-switch evidence.
- [ ] `apps/edge/internal/openai/stream_gate_dispatcher_test.go` — provide controller/binding regression evidence.

#### Test Strategy

The new and extended tests are required; do not replace them with mock-only label construction. Review must see one handler/runtime path that switches actual providers and reads provider-reported usage.

#### Verification

```bash
rg --sort path -n 'route_model|usage_attribution|provider_id|served_model|terminal counter' agent-contract/outer/openai-compatible-api.md agent-spec/input/openai-compatible-surface.md
GOCACHE=/tmp/iop-s1-provider-usage-attribution-gocache go test -count=1 ./apps/edge/internal/openai -run 'UsageAttribution|ProviderSwitch|UsageMetrics'
```

Expected: contract/spec language matches passing S01-S03 tests and does not add Grafana query changes.

## Dependencies and Execution Order

1. `01_attribution_binding_foundation` must produce `complete.log` before implementation starts; the dependency is encoded by `02+01_attempt_usage_emission` and must be resolved through the active path or an exact archived completion path supplied by the task loop, without broad archive discovery.
2. Complete API-1 before replacing lifecycle call sites.
3. Complete API-2 before locking API-3 evidence and running final verification.

## Modified Files Summary

| File | Item |
|------|------|
| `apps/edge/internal/openai/usage_metrics.go` | API-1 |
| `apps/edge/internal/openai/dispatch_context.go` | API-1 |
| `apps/edge/internal/openai/usage_metrics_test.go` | API-1 |
| `apps/edge/internal/openai/stream_gate_dispatcher.go` | API-2 |
| `apps/edge/internal/openai/stream_gate_runtime.go` | API-2 |
| `apps/edge/internal/openai/responses_stream_gate.go` | API-2 |
| `apps/edge/internal/openai/chat_handler.go` | API-2 |
| `apps/edge/internal/openai/chat_completion.go` | API-2 |
| `apps/edge/internal/openai/buffered_sse.go` | API-2 |
| `apps/edge/internal/openai/chat_stream_session.go` | API-2 |
| `apps/edge/internal/openai/provider_tunnel.go` | API-2 |
| `apps/edge/internal/openai/responses_handler.go` | API-2 |
| `apps/edge/internal/openai/responses_completion.go` | API-2 |
| `apps/edge/internal/openai/provider_observation.go` | API-2 |
| `agent-contract/outer/openai-compatible-api.md` | API-3 |
| `agent-spec/input/openai-compatible-surface.md` | API-3 |
| `apps/edge/internal/openai/usage_attribution_attempt_test.go` | API-3 |
| `apps/edge/internal/openai/stream_gate_dispatcher_test.go` | API-3 |
| `agent-task/m-provider-usage-attribution-hot-path/02+01_attempt_usage_emission/CODE_REVIEW-cloud-G10.md` | API-1, API-2, API-3 |

## Final Verification

```bash
mkdir -p /tmp/iop-s1-provider-usage-attribution-gocache
test -z "$(gofmt -l apps/edge/internal/openai/usage_metrics.go apps/edge/internal/openai/dispatch_context.go apps/edge/internal/openai/stream_gate_dispatcher.go apps/edge/internal/openai/stream_gate_runtime.go apps/edge/internal/openai/responses_stream_gate.go apps/edge/internal/openai/chat_handler.go apps/edge/internal/openai/chat_completion.go apps/edge/internal/openai/buffered_sse.go apps/edge/internal/openai/chat_stream_session.go apps/edge/internal/openai/provider_tunnel.go apps/edge/internal/openai/responses_handler.go apps/edge/internal/openai/responses_completion.go apps/edge/internal/openai/provider_observation.go apps/edge/internal/openai/usage_metrics_test.go apps/edge/internal/openai/usage_attribution_attempt_test.go apps/edge/internal/openai/stream_gate_dispatcher_test.go)"
GOCACHE=/tmp/iop-s1-provider-usage-attribution-gocache go test -count=1 ./packages/go/config ./apps/edge/internal/service ./apps/edge/internal/openai
GOCACHE=/tmp/iop-s1-provider-usage-attribution-gocache go test -race -count=1 ./apps/edge/internal/openai
./scripts/e2e-provider-capacity-smoke.sh
rg --sort path -n 'route_model|usage_attribution|provider_id|served_model|terminal counter' agent-contract/outer/openai-compatible-api.md agent-spec/input/openai-compatible-surface.md
git diff --check
```

Expected: formatting is clean; fresh package and race tests pass; local provider-capacity smoke passes; S01-S03 contract/spec pointers match; diff check passes. Cached test output is not acceptable. After completing all code changes, fill implementation-owned sections in `CODE_REVIEW-*-G??.md`.
