Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions app/modules/proxy/_service/api_key_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,34 @@ async def _settle_compact_api_key_usage(
finally:
_signal_propagated_responses_service_cleanup_ready()

async def settle_image_api_key_usage(
self,
api_key: ApiKeyData | None,
reservation: ApiKeyUsageReservationData | None,
*,
model: str,
input_tokens: int | None,
output_tokens: int | None,
cached_input_tokens: int | None,
request_id: str,
) -> bool:
"""Transfer captured image usage to tracked reservation settlement."""
has_usage = input_tokens is not None or output_tokens is not None
settlement = _StreamSettlement(
status="success" if has_usage else "failed",
model=model,
input_tokens=int(input_tokens or 0) if has_usage else None,
output_tokens=int(output_tokens or 0) if has_usage else None,
cached_input_tokens=int(cached_input_tokens or 0) if has_usage else None,
service_tier=None,
)
return await self._settle_stream_api_key_usage(
api_key,
reservation,
settlement,
request_id=request_id,
)

async def _settle_stream_api_key_usage(
self,
api_key: ApiKeyData | None,
Expand Down
59 changes: 20 additions & 39 deletions app/modules/proxy/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3179,6 +3179,8 @@ async def _stream_with_log_rewrite() -> AsyncIterator[bytes]:
_output = captured.get("image_output_tokens")
_cached = captured.get("image_cached_input_tokens")
await _finalize_image_reservation(
context.service,
api_key,
reservation,
model=public_model,
input_tokens=_input if isinstance(_input, int) else None,
Expand Down Expand Up @@ -3232,6 +3234,8 @@ async def _stream_with_log_rewrite() -> AsyncIterator[bytes]:
_output = captured.get("image_output_tokens")
_cached = captured.get("image_cached_input_tokens")
await _finalize_image_reservation(
context.service,
api_key,
reservation,
model=public_model,
input_tokens=_input if isinstance(_input, int) else None,
Expand Down Expand Up @@ -3474,6 +3478,8 @@ async def _stream_with_log_rewrite() -> AsyncIterator[bytes]:
_output = captured.get("image_output_tokens")
_cached = captured.get("image_cached_input_tokens")
await _finalize_image_reservation(
context.service,
api_key,
reservation,
model=public_model,
input_tokens=_input if isinstance(_input, int) else None,
Expand Down Expand Up @@ -3527,6 +3533,8 @@ async def _stream_with_log_rewrite() -> AsyncIterator[bytes]:
_output = captured.get("image_output_tokens")
_cached = captured.get("image_cached_input_tokens")
await _finalize_image_reservation(
context.service,
api_key,
reservation,
model=public_model,
input_tokens=_input if isinstance(_input, int) else None,
Expand Down Expand Up @@ -7651,54 +7659,27 @@ async def _release_reservation_best_effort(


async def _finalize_image_reservation(
service: proxy_service_module.ProxyService,
api_key: ApiKeyData | None,
reservation: ApiKeyUsageReservationData | None,
*,
model: str,
input_tokens: int | None,
output_tokens: int | None,
cached_input_tokens: int | None = None,
) -> None:
"""Finalize the API-key usage reservation for a ``/v1/images/*`` call.

The image adapter bypasses the standard stream settlement (``stream_responses``
is invoked with ``api_key_reservation=None``) because the ``image_generation``
tool path typically leaves ``response.usage`` empty; charging from
``tool_usage.image_gen`` is the only source of truth. This helper
finalizes the reservation with the captured image tokens when present,
otherwise releases it. Calling this exactly once per request prevents
the double-billing scenario where both the standard settlement and
the post-hoc image record_usage path increment limits.

Persistence errors are caught and logged so a transient DB/session
failure during the tail accounting cannot turn a successfully
generated image into a user-facing 500 (non-streaming) or an
abrupt stream termination (streaming). This mirrors the
best-effort accounting policy used by
``ProxyService._settle_stream_api_key_usage``.
"""
"""Transfer image-token settlement to tracked persistence ownership."""
if reservation is None:
return
try:
if not input_tokens and not output_tokens:
await _release_reservation(reservation)
return
async with get_background_session() as session:
service = ApiKeysService(ApiKeysRepository(session))
await service.finalize_usage_reservation(
reservation.reservation_id,
model=model,
input_tokens=int(input_tokens or 0),
output_tokens=int(output_tokens or 0),
cached_input_tokens=int(cached_input_tokens or 0),
service_tier=None,
)
except Exception:
logger.warning(
"failed to finalize image reservation reservation_id=%s model=%s",
reservation.reservation_id,
model,
exc_info=True,
)
await service.settle_image_api_key_usage(
api_key,
reservation,
model=model,
input_tokens=input_tokens,
output_tokens=output_tokens,
cached_input_tokens=cached_input_tokens,
request_id=get_request_id() or reservation.reservation_id,
)


async def _settle_source_reservation(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-19
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
## Context

Image generation and edit reserve limited API-key quota before invoking the
internal Responses pipeline. They intentionally pass no reservation into that
pipeline because image usage comes from `tool_usage.image_gen`, not
`response.usage`. The image adapter therefore owns final settlement.

The current adapter performs finalization inline after it has already produced
the public image result. A persistence failure rolls the reservation back to
`reserved`; the adapter logs and returns, so no request-scoped owner remains.
The standard Responses settlement path already provides detached task tracking,
cancellation handoff, retrying release, bounded repository concurrency, and
graceful persistence drain.

## Goals / Non-Goals

**Goals**

- Transfer image reservation ownership exactly once to the existing tracked
settlement machinery.
- Finalize captured image tokens when persistence succeeds.
- Preserve the completed public response while failed or cancelled settlement
transfers ownership to the existing retrying release fallback.
- Keep generation/edit and streaming/non-streaming behavior aligned.

**Non-Goals**

- Define image-only retries of authoritative token finalization.
- Change repository states, retry timings, concurrency limits, stale-reset
policy, database schema, settings, or external response shapes.
- Give the internal Responses stream a second settlement owner.
- Broaden pre-terminal image cancellation cleanup.

## Decisions

### Reuse tracked stream settlement ownership

Add one image-facing adapter on the API-key usage mixin. The adapter constructs
the existing settlement value from the public image model, captured image
tokens, API-key data, reservation, service tier `None`, and request id, then
delegates to the existing tracked settlement entrypoint.

This keeps task registration, cancellation callbacks, retrying release, and
persistence drain in one implementation rather than copying lifecycle logic
into the route module.

### Preserve image-token authority

When at least one captured image token field is usable, the adapter records a
successful settlement and normalizes missing token fields to zero. When no
captured image usage is usable, it selects the existing non-success settlement
path so the reservation releases instead of recording fabricated usage.

The internal Responses call continues receiving `api_key_reservation=None`.

### Transfer ownership before returning the completed result

All four image completion paths call the same adapter exactly once. The adapter
returns after the settlement task is registered; the public response does not
wait for persistence. If tracked finalization fails or is cancelled, its done
callback transfers ownership synchronously to the retrying release task.

Exactly-once refers to the terminal database mutation. Retried release attempts
remain safe because repository transitions claim only a still-reserved row.

## Risks / Trade-offs

- A failed finalization falls back to release, so successful image usage can be
omitted under persistence failure. This matches existing standard stream
policy and is preferable to keeping quota ownerless. Retrying authoritative
finalization is a broader accounting-policy change and remains separate.
- Reusing a private settlement value couples the adapter to existing settlement
internals. Keeping construction inside the mixin limits that coupling and
avoids route-level task lifecycle duplication.
- Permanent release failure leaves quota conservatively reserved, but the task
remains visible to persistence drain and the stale reaper remains a final
process-restart fallback.

## Migration Plan

No migration or rollout setting is required. Existing terminal reservations are
unchanged; new image completions use tracked settlement after deployment.

Rollback restores inline image finalization behavior without data conversion.

## Open Questions

None for this change. Stronger retries of authoritative image finalization are
explicit follow-up scope.
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
## Why

Image generation and edit routes reserve limited API-key quota but deliberately
exclude that reservation from the internal Responses stream settlement path.
Their image-specific finalizer currently logs and abandons the reservation when
persistence fails, leaving quota charged until stale cleanup and leaving
graceful persistence drain unaware of the unresolved work.

## What Changes

- Transfer image reservation settlement to the existing tracked,
cancellation-safe stream settlement machinery while preserving captured
`tool_usage.image_gen` tokens as the authoritative usage source.
- Preserve successful public Images JSON and SSE responses when settlement
fails or is cancelled.
- Transfer failed or cancelled finalization to the existing tracked,
retrying release fallback so persistence drain remains aware of unresolved
ownership.
- Keep the internal Responses stream reservation-free to prevent duplicate
settlement across image and standard response paths.

## Capabilities

### Modified Capabilities

- `images-api-compat`: require successful image generation and edit paths to
retain tracked reservation ownership through finalization or fallback release.

## Impact

- Affects the image generation/edit settlement handoff in
`app/modules/proxy/api.py` and the reusable API-key settlement seam in
`app/modules/proxy/_service/api_key_usage.py`.
- Adds event-driven integration coverage for finalization failure,
cancellation, release retry, persistence drain, and all four image response
modes.
- Does not change database schema, API-key repository transitions, retry
constants, scheduler policy, external response schemas, or settings.
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
## MODIFIED Requirements

### Requirement: Image routes participate in usage accounting and policy

The system SHALL apply API-key allowed-model policy and model-scoped usage
limits to `/v1/images/*` using the publicly-requested `gpt-image-*` value as the
effective model. The system SHALL record the publicly-requested `gpt-image-*`
value (not the internal host model) in the request log's `model` column once the
upstream response id becomes known. A successful image generation or edit that
owns a limited API-key reservation SHALL transfer that reservation exactly once
to persistence-drained settlement using captured `tool_usage.image_gen` tokens,
while the internal Responses stream SHALL NOT receive a second settlement
owner. Failed or cancelled finalization SHALL preserve the completed public
image response and transfer ownership to the tracked retrying release fallback.

#### Scenario: API key allowed-model policy blocks gpt-image-2

- **WHEN** an API key's `allowed_models` list does not include `gpt-image-2`
- **THEN** requests to `/v1/images/generations` or `/v1/images/edits` with `model=gpt-image-2` return 403 `model_not_allowed`

#### Scenario: Request log surfaces the publicly requested image model

- **WHEN** an `/v1/images/*` request completes successfully against an internal host Responses model (for example `gpt-5.5`)
- **THEN** the resulting `request_logs` row has `model` equal to the publicly requested value (for example `gpt-image-2`) so dashboards and usage views surface the user-visible model rather than the internal host model

#### Scenario: Failed image-token settlement retains tracked release ownership

- **GIVEN** a limited API key owns a reservation for a successful image generation or edit request
- **AND** the internal Responses stream receives no API-key reservation
- **AND** the image adapter captures authoritative `tool_usage.image_gen` tokens
- **WHEN** tracked finalization fails or is cancelled while the reservation remains `reserved`
- **THEN** the completed public Images JSON response or SSE completion remains available
- **AND** settlement ownership transfers to a persistence-drained fallback release task
- **AND** transient release failures keep that task tracked and retrying until release succeeds or graceful persistence drain reports timeout
- **AND** a successful fallback restores pre-reserved quota exactly once without recording `response.usage` or starting a second image settlement
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
## 1. Regression Coverage

- [x] 1.1 Replace the unkeyed finalization-failure case with a limited-key integration that proves the completed image response transfers an unresolved reservation to tracked release ownership
- [x] 1.2 Add event-driven coverage for settlement cancellation and retrying release while persistence drain remains pending
- [x] 1.3 Cover generation and edit, streaming and non-streaming, to prove exactly one image settlement handoff and no internal Responses reservation owner

## 2. Tracked Image Settlement

- [x] 2.1 Add an image-facing API-key usage adapter that delegates captured image tokens to the existing tracked stream settlement lifecycle
- [x] 2.2 Route all four image completion paths through the adapter while preserving public response availability and public model attribution

## 3. Verification

- [x] 3.1 Run focused image and settlement tests, Ruff, type checking, proxy architecture checks, and strict affected OpenSpec validation
- [x] 3.2 Exercise the isolated HTTP image surface with a limited key, gated release retry, persistence drain, and real SQLite state assertions
- [x] 3.3 Verify implementation against this change, synchronize the delta, and archive the verified OpenSpec change
22 changes: 21 additions & 1 deletion openspec/specs/images-api-compat/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,16 @@ When a client requests `stream=true` on `/v1/images/generations` or `/v1/images/

### Requirement: Image routes participate in usage accounting and policy

The system SHALL apply API-key allowed-model policy and model-scoped usage limits to `/v1/images/*` using the publicly-requested `gpt-image-*` value as the effective model. The system SHALL record the publicly-requested `gpt-image-*` value (not the internal host model) in the request log's `model` column once the upstream response id becomes known.
The system SHALL apply API-key allowed-model policy and model-scoped usage
limits to `/v1/images/*` using the publicly-requested `gpt-image-*` value as the
effective model. The system SHALL record the publicly-requested `gpt-image-*`
value (not the internal host model) in the request log's `model` column once the
upstream response id becomes known. A successful image generation or edit that
owns a limited API-key reservation SHALL transfer that reservation exactly once
to persistence-drained settlement using captured `tool_usage.image_gen` tokens,
while the internal Responses stream SHALL NOT receive a second settlement
owner. Failed or cancelled finalization SHALL preserve the completed public
image response and transfer ownership to the tracked retrying release fallback.

#### Scenario: API key allowed-model policy blocks gpt-image-2

Expand All @@ -101,6 +110,17 @@ The system SHALL apply API-key allowed-model policy and model-scoped usage limit
- **WHEN** an `/v1/images/*` request completes successfully against an internal host Responses model (for example `gpt-5.5`)
- **THEN** the resulting `request_logs` row has `model` equal to the publicly requested value (for example `gpt-image-2`) so dashboards and usage views surface the user-visible model rather than the internal host model

#### Scenario: Failed image-token settlement retains tracked release ownership

- **GIVEN** a limited API key owns a reservation for a successful image generation or edit request
- **AND** the internal Responses stream receives no API-key reservation
- **AND** the image adapter captures authoritative `tool_usage.image_gen` tokens
- **WHEN** tracked finalization fails or is cancelled while the reservation remains `reserved`
- **THEN** the completed public Images JSON response or SSE completion remains available
- **AND** settlement ownership transfers to a persistence-drained fallback release task
- **AND** transient release failures keep that task tracked and retrying until release succeeds or graceful persistence drain reports timeout
- **AND** a successful fallback restores pre-reserved quota exactly once without recording `response.usage` or starting a second image settlement

### Requirement: Image routes expose bounded operational observability

The system SHALL emit structured route-completion logs and Prometheus metrics for `/v1/images/generations` and `/v1/images/edits`. Observability labels MUST be bounded to route, effective public model, stream flag, HTTP status, and outcome, and MUST NOT include prompts, image bytes, file names, access tokens, or raw upstream payloads.
Expand Down
Loading
Loading