Skip to content
Closed
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
8 changes: 8 additions & 0 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,14 @@ those providers, but `ocx login codex --reauth` routes to their account-pool rea
the dashboard Codex account pool also performs. See
[`ocx status` / `ocx doctor`](/reference/cli/) in the CLI reference.

### Kiro request credits

When Kiro emits credit metering, request logs preserve the reported spend as
`usage.providerCredits`, including in the persisted usage ledger. These are Kiro credits;
token counts may still be estimated, and the credit value does not replace USD cost estimates.
Completion fallback requests add their reported credits. An absent value means Kiro did not
report credit usage; an explicit zero means it reported no spend.

### Kiro credential import

Kiro login expects the Kiro CLI: on Unix, install it with `curl -fsSL https://cli.kiro.dev/install | bash`;
Expand Down
2 changes: 2 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1077,6 +1077,8 @@
"kiro-remote-image.test.ts": "providers/kiro",
"kiro-retry.test.ts": "providers/kiro",
"kiro-review-regressions.test.ts": "providers/kiro",
"kiro-metering-events.test.ts": "providers/kiro",
"kiro-metering-usage.test.ts": "providers/kiro",
"kiro-stream.test.ts": "providers/kiro",
"kiro-usage-quota.test.ts": "providers/kiro",
"kiro-windows-cli-db-path.test.ts": "providers/kiro",
Expand Down
27 changes: 26 additions & 1 deletion src/adapters/kiro-events.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { OcxUsage } from "../types";
import { debugProviderDiagnostic } from "../lib/debug";
import { kiroTruncationReason } from "./kiro-truncation";

export type ParsedKiroEvent =
| { type: "content"; data?: string; modelId?: string }
| { type: "reasoning"; data?: string; signature?: string; redactedContent?: string }
| { type: "context_usage"; contextUsagePercentage: number }
| { type: "metering"; unit: string; usage: number; unitPlural?: string }
| { type: "tool"; name?: string; toolUseId?: string; input?: string; stop?: boolean }
| { type: "truncation"; data: string }
| { type: "metadata"; usage?: OcxUsage; contextUsagePercentage?: number; stopReason?: string }
Expand All @@ -17,10 +19,12 @@ const KNOWN_EVENT_TYPES = new Set([
"reasoningContentEvent",
"toolUseEvent",
"messageMetadataEvent",
"initial-response",
"metadataEvent",
// Authoritative context pressure. Every capture (kiro-cli 2.14.1 and 2.16.0) put the percentage
// HERE and left `metadataEvent` carrying only `stopReason`; metadataEvent's own
// contextUsagePercentage stays supported as a fallback rather than being dropped.
"meteringEvent",
"contextUsageEvent",
"invalidStateEvent",
"error",
Expand Down Expand Up @@ -111,7 +115,10 @@ function parseTokenUsage(eventType: string, value: unknown): OcxUsage | undefine
/** Decode a known Kiro event using its Smithy `:event-type` header. */
export function parseKiroEvent(eventType: string, payload: Uint8Array): ParsedKiroEvent | null {
// Unknown event types are intentionally ignored without parsing or logging their payload.
if (!KNOWN_EVENT_TYPES.has(eventType)) return null;
if (!KNOWN_EVENT_TYPES.has(eventType)) {
debugProviderDiagnostic("kiro", "unknown_event", { eventType });
return null;
}
const parsed = parseObject(eventType, payload);
// A metadataEvent's `stopReason` is Kiro's own terminal verdict and must reach the parser
// intact. The generic truncation sniffer matches substrings ("max_tokens", "length",
Expand Down Expand Up @@ -174,7 +181,25 @@ export function parseKiroEvent(eventType: string, payload: Uint8Array): ParsedKi
? { stop: optionalBoolean(eventType, parsed, "stop") }
: {}),
};
case "meteringEvent": {
const unit = optionalString(eventType, parsed, "unit");
if (unit === undefined) {
return malformed(eventType, "unit must be a string");
}
const unitPlural = optionalString(eventType, parsed, "unitPlural");
const rawUsage = parsed.usage !== undefined ? parsed.usage : parsed.amount;
if (typeof rawUsage !== "number" || !Number.isFinite(rawUsage) || rawUsage < 0) {
return malformed(eventType, "usage must be a finite non-negative number");
}
return {
type: "metering",
unit,
usage: rawUsage,
...(unitPlural !== undefined ? { unitPlural } : {}),
};
}
case "messageMetadataEvent":
case "initial-response":
return {
type: "message_metadata",
conversationId:
Expand Down
11 changes: 10 additions & 1 deletion src/adapters/kiro/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ function mergeKiroUsage(
...(sumOptional("cachedInputTokens") !== undefined ? { cachedInputTokens: sumOptional("cachedInputTokens") } : {}),
...(sumOptional("cacheReadInputTokens") !== undefined ? { cacheReadInputTokens: sumOptional("cacheReadInputTokens") } : {}),
...(sumOptional("cacheCreationInputTokens") !== undefined ? { cacheCreationInputTokens: sumOptional("cacheCreationInputTokens") } : {}),
...(sumOptional("providerCredits") !== undefined ? { providerCredits: sumOptional("providerCredits") } : {}),
...(sumOptional("reasoningOutputTokens") !== undefined ? { reasoningOutputTokens: sumOptional("reasoningOutputTokens") } : {}),
...(first.estimated || second.estimated ? { estimated: true } : {}),
};
Expand Down Expand Up @@ -319,6 +320,7 @@ async function* parseKiroAttemptEvents(
let completionAnswer: string | undefined;
let completionCalls = 0;
let authoritativeUsage: OcxUsage | undefined;
let providerCredits: number | undefined;
let stopReason: string | undefined;
const fallbackEvents: AdapterEvent[] = [];
const thinking = new InlineThinkTagParser(budget);
Expand Down Expand Up @@ -379,7 +381,11 @@ async function* parseKiroAttemptEvents(
contextUsageTotalFloor() ?? 0,
authoritativeTurnTotal,
);
return contextTotal > 0 ? { ...base, contextTotalTokens: contextTotal } : base;
return {
...base,
...(contextTotal > 0 ? { contextTotalTokens: contextTotal } : {}),
...(providerCredits !== undefined ? { providerCredits } : {}),
};
};

const classifiedTerminal = (failure: KiroErrorClassification): AdapterEvent => {
Expand Down Expand Up @@ -599,6 +605,9 @@ async function* parseKiroAttemptEvents(
const ev = parseKiroEvent(eventType, msg.payload);
if (!ev) continue;
switch (ev.type) {
case "metering":
if (ev.unit === "credit" || ev.unit === "credits") providerCredits = ev.usage;
break;
case "metadata":
if (ev.usage) authoritativeUsage = ev.usage;
if (ev.contextUsagePercentage !== undefined && ev.contextUsagePercentage > 0) {
Expand Down
4 changes: 3 additions & 1 deletion src/server/request-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1905,7 +1905,7 @@ export function aggregateAttemptUsage(

const sumOptional = (
key: "cachedInputTokens" | "cacheReadInputTokens" | "cacheCreationInputTokens"
| "reasoningOutputTokens",
| "reasoningOutputTokens" | "providerCredits",
): number | undefined => {
const present = usages.flatMap(usage => (
typeof usage[key] === "number" ? [usage[key] as number] : []
Expand All @@ -1916,6 +1916,7 @@ export function aggregateAttemptUsage(
const cacheReadInputTokens = sumOptional("cacheReadInputTokens");
const cacheCreationInputTokens = sumOptional("cacheCreationInputTokens");
const reasoningOutputTokens = sumOptional("reasoningOutputTokens");
const providerCredits = sumOptional("providerCredits");
const totalTokens = usages.reduce(
(sum, usage) => sum + (usageTotalTokens(usage) ?? 0),
0,
Expand All @@ -1928,6 +1929,7 @@ export function aggregateAttemptUsage(
...(cacheReadInputTokens !== undefined ? { cacheReadInputTokens } : {}),
...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}),
...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}),
...(providerCredits !== undefined ? { providerCredits } : {}),
...(status === "estimated" ? { estimated: true } : {}),
};
return { usage: aggregate, status, totalTokens };
Expand Down
2 changes: 2 additions & 0 deletions src/server/responses/empty-completion-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ export function mergeUsage(
const cacheReadInputTokens = sumOptional("cacheReadInputTokens");
const cacheCreationInputTokens = sumOptional("cacheCreationInputTokens");
const reasoningOutputTokens = sumOptional("reasoningOutputTokens");
const providerCredits = sumOptional("providerCredits");
const contextTotalTokens = second.contextTotalTokens ?? first.contextTotalTokens;
const inputTokens = first.inputTokens + second.inputTokens;
const outputTokens = first.outputTokens + second.outputTokens;
Expand All @@ -188,6 +189,7 @@ export function mergeUsage(
...(cacheReadInputTokens !== undefined ? { cacheReadInputTokens } : {}),
...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}),
...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}),
...(providerCredits !== undefined ? { providerCredits } : {}),
...(first.estimated || second.estimated ? { estimated: true } : {}),
...(rawUsage !== undefined ? { rawUsage } : {}),
};
Expand Down
2 changes: 2 additions & 0 deletions src/server/responses/terminal-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ function mergeUsage(first: OcxUsage | undefined, second: OcxUsage | undefined):
const cacheReadInputTokens = sumOptional("cacheReadInputTokens");
const cacheCreationInputTokens = sumOptional("cacheCreationInputTokens");
const reasoningOutputTokens = sumOptional("reasoningOutputTokens");
const providerCredits = sumOptional("providerCredits");
const inputTokens = first.inputTokens + second.inputTokens;
const outputTokens = first.outputTokens + second.outputTokens;
return {
Expand All @@ -194,6 +195,7 @@ function mergeUsage(first: OcxUsage | undefined, second: OcxUsage | undefined):
...(cacheReadInputTokens !== undefined ? { cacheReadInputTokens } : {}),
...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}),
...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}),
...(providerCredits !== undefined ? { providerCredits } : {}),
...(first.estimated || second.estimated ? { estimated: true } : {}),
};
}
Expand Down
2 changes: 2 additions & 0 deletions src/types/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,8 @@ export interface OcxUrlCitation {
* - `totalTokens` = inputTokens + outputTokens. Never re-add cache detail on top.
*/
export interface OcxUsage {
/** Provider-reported credit spend, independent of token estimates and USD pricing. */
providerCredits?: number;
inputTokens: number;
outputTokens: number;
/**
Expand Down
2 changes: 2 additions & 0 deletions src/usage/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,7 @@ function normalizeUsageValue(usage: OcxUsage | undefined): OcxUsage | undefined
...(typeof usage.cacheReadInputTokens === "number" ? { cacheReadInputTokens: usage.cacheReadInputTokens } : {}),
...(typeof usage.cacheCreationInputTokens === "number" ? { cacheCreationInputTokens: usage.cacheCreationInputTokens } : {}),
...(typeof usage.reasoningOutputTokens === "number" ? { reasoningOutputTokens: usage.reasoningOutputTokens } : {}),
...(isNonNegativeFiniteNumber(usage.providerCredits) ? { providerCredits: usage.providerCredits } : {}),
...(usage.estimated ? { estimated: true } : {}),
};
}
Expand Down Expand Up @@ -622,6 +623,7 @@ function normalizeAttemptUsage(raw: unknown): OcxUsage | null {
"cacheReadInputTokens",
"cacheCreationInputTokens",
"reasoningOutputTokens",
"providerCredits",
] as const) {
if (key in usage && !isNonNegativeFiniteNumber(usage[key])) return null;
}
Expand Down
5 changes: 5 additions & 0 deletions structure/dashboard-and-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,11 @@ keeps the saved state and renders fixed `ocx sync` guidance without server/accou

## Usage accounting

`OcxUsage.providerCredits` preserves provider-reported credit spend in request and attempt rows
through `src/usage/log.ts` normalization and ledger reloads. Missing readings stay absent, and zero
is a measured value. Separate attempts add credits when usage is merged. The field is independent
of token estimation (`estimated` describes tokens) and is never treated as USD or token usage.

### Upstream key account attribution

API-key attempts in `src/usage/log.ts` carry `accountLogLabel` as `k` plus 32 lowercase
Expand Down
3 changes: 3 additions & 0 deletions structure/providers-and-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ Inline document admission shares one encoding predicate between its scanner and
`src/responses/inline-document.ts`: malformed base64 quantum/padding lengths are refused,
and valid padded or unpadded payloads pass unchanged without a decoding allocation.

Kiro metering uses the [provider credit contract](providers/kiro.md#kiro-reasoning-round-trip-signature);
`src/types/request.ts` keeps reported credits separate from estimated token usage.

Adapter output must stay in internal `AdapterEvent` form until `src/bridge/sse.ts` converts it back
to Responses SSE or WebSocket frames, or `src/bridge/response-json.ts` buffers it into a JSON
response. `src/bridge.ts` is the compatibility facade that re-exports both.
Expand Down
13 changes: 11 additions & 2 deletions structure/providers/kiro.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,17 @@ from `metadataEvent` is legitimate rather than impossible. Both feed the same fi
positive value overwrites an earlier one.

Spend arrives in `meteringEvent` as **credits, not tokens**. No captured response carried
`tokenUsage` on any event, which is why Kiro usage stays estimated; `meteringEvent` is currently
ignored because a credit is not a token count.
`tokenUsage` on any event, which is why Kiro token usage stays estimated. The parser preserves
`meteringEvent` unit/usage (`amount` is an alias) and optional `unitPlural`; credit readings populate
`OcxUsage.providerCredits` independently of token metadata. The latest reading within a response
is a snapshot; separate completion-fallback responses add their credits. Missing metering stays
absent and measured zero stays zero. `initial-response` carries `conversationId` through the same
validated provider-state path as `messageMetadataEvent`. Unknown event types produce opt-in
`debugProviderDiagnostic` entries containing only the event type, never the payload.
Coverage: `tests/providers/kiro/kiro-metering-events.test.ts`,
`tests/providers/kiro/kiro-metering-usage.test.ts`, and
`tests/server/server-kiro-completion-e2e.test.ts`.

## Remote image references

Kiro's wire inlines base64 bytes only, so a remote `https` image reference cannot be
Expand Down
2 changes: 2 additions & 0 deletions tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -898,6 +898,8 @@
"kiro-remote-image.test.ts": "providers/kiro",
"kiro-retry.test.ts": "providers/kiro",
"kiro-review-regressions.test.ts": "providers/kiro",
"kiro-metering-events.test.ts": "providers/kiro",
"kiro-metering-usage.test.ts": "providers/kiro",
"kiro-stream.test.ts": "providers/kiro",
"kiro-usage-quota.test.ts": "providers/kiro",
"kiro-windows-cli-db-path.test.ts": "providers/kiro",
Expand Down
Loading
Loading