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
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@
"cli-models-runtime-dispatch.test.ts": "cli",
"cli-models.test.ts": "cli",
"cli-native-profile.test.ts": "cli",
"cli-observe-logs.test.ts": "cli",
"cli-provider.test.ts": "cli",
"cli-ready-subprocess.test.ts": "cli",
"cli-ready.test.ts": "cli",
Expand Down
4 changes: 3 additions & 1 deletion skills/ocx/references/01_management_surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ JSON mode: `payload`.

### `ocx logs`

Recent request log rows, filterable by provider, model, conversation, and status.
Recent request log rows, filterable by provider, model, conversation, account, and status.

| Method | Route |
|---|---|
Expand All @@ -156,6 +156,7 @@ Recent request log rows, filterable by provider, model, conversation, and status
| `--provider` | string | Restrict to one provider, matching failover attempts too. |
| `--model` | string | Restrict to one model id, matching failover attempts too. |
| `--conversation` | string | Restrict to one conversation id (`--conversationId` is accepted too). |
| `--account` | string | Restrict to one account log label (`main`, `p<hex6>`, `o<hex6>`), matching failover attempts too. |
| `--status` | string | An exact code (429) or a class (5xx). |
| `--limit` | number | Row cap; defaults to 200. |
| `--follow` | boolean | Poll for new rows; add --jsonl to emit JSONL. |
Expand All @@ -166,6 +167,7 @@ JSON mode: `payload`.

- `--provider` and `--model` both match a failover attempt, so a request is findable by what actually served it, not only by what was asked for.
- Rows print `conv=<id>` when the entry carries one, so a conversation filter can be told apart from an empty result.
- Rows print `acct=<label>` when the account is known, so an `--account` filter can be told apart from an empty result.
- `--follow` deduplicates by row id and cannot be combined with `--json`.

### `ocx storage report`
Expand Down
2 changes: 1 addition & 1 deletion skills/ocx/references/02_json_shapes.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ One row per line. The fields worth branching on:
|---|---|
| `requestId` | pass to `ocx logs explain` |
| `conversationId` | groups a conversation; also printed as `conv=<id>` in human output |
| `accountLogLabel` | which account served it (`main`, `p<hex6>`, `o<hex6>`); also printed as `acct=<label>` in human output |
| `provider` / `model` | what actually served it |
| `requestedModel` / `requestedAlias` | what the client asked for |
| `status` / `durationMs` | outcome |
Expand Down Expand Up @@ -127,4 +128,3 @@ hint: <what to do>
Branch on `reason` in those stderr lines, never on the message prose. `--json` does **not** wrap
API failures in `{error:{type,code,message}}`; `runCliAction` still prints the three-liner on
stderr and returns 4/5/1. Do not parse stdout for an error envelope that is not there.

12 changes: 12 additions & 0 deletions skills/ocx/references/03_recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,18 @@ Read `accounts[]`. Two things to respect:

- A row with `ambiguous: true` (label `legacy-ambiguous`) aggregates several accounts from before
labelling existed. Do not read it as one identity.

For the per-REQUEST view of the same identity, filter the log by the account label:

```bash
ocx logs --account p3f9a1 --jsonl
```

The label is the stable non-PII digest the proxy already persists — `main` and `p<hex6>` for Codex
pool accounts, `o<hex6>` for other OAuth providers — never an email or an upstream account id.
Rows served by a single-account provider carry no label. Like `--provider` and `--model`, the
filter matches failover attempts, so the request is findable by the account that finally served it.
Human output prints `acct=<label>` so a filtered result can be told apart from an empty one.
- Per-account totals are **withheld** under `--provider` or `--model`, because account rows cannot
be honestly re-partitioned that way. The report says so rather than printing an empty table.

Expand Down
4 changes: 3 additions & 1 deletion src/cli/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,12 +307,13 @@ export const CAPABILITIES: readonly Capability[] = [
},
{
command: ["logs"],
summary: "Recent request log rows, filterable by provider, model, conversation, and status.",
summary: "Recent request log rows, filterable by provider, model, conversation, account, and status.",
routes: [{ method: "GET", path: "/api/logs" }],
flags: [
{ name: "--provider", value: "string", summary: "Restrict to one provider, matching failover attempts too." },
{ name: "--model", value: "string", summary: "Restrict to one model id, matching failover attempts too." },
{ name: "--conversation", value: "string", summary: "Restrict to one conversation id (`--conversationId` is accepted too)." },
{ name: "--account", value: "string", summary: "Restrict to one account log label (`main`, `p<hex6>`, `o<hex6>`), matching failover attempts too." },
{ name: "--status", value: "string", summary: "An exact code (429) or a class (5xx)." },
{ name: "--limit", value: "number", summary: "Row cap; defaults to 200." },
{ name: "--follow", value: "boolean", summary: "Poll for new rows; add --jsonl to emit JSONL." },
Expand All @@ -324,6 +325,7 @@ export const CAPABILITIES: readonly Capability[] = [
details: [
"`--provider` and `--model` both match a failover attempt, so a request is findable by what actually served it, not only by what was asked for.",
"Rows print `conv=<id>` when the entry carries one, so a conversation filter can be told apart from an empty result.",
"Rows print `acct=<label>` when the account is known, so an `--account` filter can be told apart from an empty result.",
"`--follow` deduplicates by row id and cannot be combined with `--json`.",
],
},
Expand Down
16 changes: 13 additions & 3 deletions src/cli/observe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { redactSecretString } from "../lib/redact";

const USAGE = `Usage:
ocx observe logs [--provider <name>] [--model <id>] [--status <code>]
[--conversation <id>] [--limit <n>] [--follow] [--json|--jsonl]
[--conversation <id>] [--account <label>] [--limit <n>] [--follow] [--json|--jsonl]
ocx logs explain <request-id> [--json]
ocx logs rebuild-index
ocx logs index-status
Expand Down Expand Up @@ -58,7 +58,14 @@ function formatLog(row: LogEntry): string {
const conversation = typeof row.conversationId === "string" && row.conversationId.length > 0
? `conv=${row.conversationId}`
: "";
return [time, String(status), route, duration, conversation].filter(Boolean).join(" ");
// The account label is printed for the same reason, and for one more: it is the answer to
// "which of my accounts served this?" (#4057). It is only ever the stable non-PII label the
// proxy already persists (`main`, `p<hex6>`, `o<hex6>`) — never an email, a key, or an
// upstream account id. Rows from a single-account provider carry no label and print none.
const account = typeof row.accountLogLabel === "string" && row.accountLogLabel.length > 0
? `acct=${row.accountLogLabel}`
: "";
return [time, String(status), route, duration, account, conversation].filter(Boolean).join(" ");
}

async function logs(argv: string[], deps: RuntimeApiDeps): Promise<void> {
Expand All @@ -72,6 +79,9 @@ async function logs(argv: string[], deps: RuntimeApiDeps): Promise<void> {
// Both spellings, because the server accepts both (`request-log.ts:1032`) and an operator
// should not have to remember which one this surface wanted.
const conversationId = takeOption(args, "--conversation") ?? takeOption(args, "--conversationId");
// Server-side, so `--limit` caps the rows that MATCHED rather than the rows scanned; a
// client-side filter after a 200-row cap would silently hide older matches.
const account = takeOption(args, "--account");
const limit = takeIntegerOption(args, "--limit", { min: 1 }) ?? 200;
rejectArgs(args, USAGE);
if (wantsJson && wantsJsonl) throw new CliUsageError("--json and --jsonl cannot be combined", USAGE);
Expand All @@ -80,7 +90,7 @@ async function logs(argv: string[], deps: RuntimeApiDeps): Promise<void> {
}
let seen = new Set<string>();
do {
const data = await runtimeRequest(`/api/logs${query({ provider, model, status, conversationId, limit })}`, {}, deps);
const data = await runtimeRequest(`/api/logs${query({ provider, model, status, conversationId, account, limit })}`, {}, deps);
const rows = logRows(data);
if (!follow && wantsJson) printData(data, true);
else {
Expand Down
10 changes: 10 additions & 0 deletions src/server/request-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1119,6 +1119,16 @@ export function filterRequestLogs(logs: RequestLogEntry[], params: URLSearchPara
filtered = filtered.filter(entry => entry.model === model
|| entry.attempts?.some(attempt => attempt.model === model));
}
// #4057: "which account served this request" is the first question asked when one provider
// holds several accounts, and until now the only way to answer it was to grep usage.jsonl by
// hand. Attempts are matched for the same reason `provider` and `model` match them: when a
// request failed over between pool accounts, a search for the account that finally served it
// has to find that request, not only the account that first refused it.
const account = params.get("account")?.trim();
if (account) {
filtered = filtered.filter(entry => entry.accountLogLabel === account
|| entry.attempts?.some(attempt => attempt.accountLogLabel === account));
}
const status = params.get("status")?.trim().toLowerCase();
if (status) {
filtered = /^[1-5]xx$/.test(status)
Expand Down
81 changes: 81 additions & 0 deletions tests/cli/cli-observe-logs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, expect, test } from "bun:test";
import { handleObserveCommand } from "../../src/cli/observe";

/**
* #4057: `ocx logs` gained an `--account` filter so an operator running several accounts behind
* one provider can ask "which requests did this account serve?" without grepping usage.jsonl.
*
* The filter is applied SERVER-side, so these tests assert the query string rather than the rows:
* filtering client-side after the row cap would silently hide older matches, and a test that only
* checked the printed output would pass either way.
*/
describe("ocx logs --account", () => {
function capture(): { lines: string[]; restore: () => void } {
const lines: string[] = [];
const original = console.log;
console.log = (...args: unknown[]) => { lines.push(args.map(String).join(" ")); };
return { lines, restore: () => { console.log = original; } };
}

test("sends the account label to /api/logs alongside the other filters", async () => {
const paths: string[] = [];
const captured = capture();
try {
const code = await handleObserveCommand(["logs", "--account", "p3f9a1", "--provider", "openai", "--limit", "5"], {
baseUrl: "http://cli.test",
fetchImpl: async input => {
paths.push(String(input).replace("http://cli.test", ""));
return new Response("[]", { status: 200, headers: { "content-type": "application/json" } });
},
});
expect(code).toBe(0);
} finally {
captured.restore();
}
expect(paths).toHaveLength(1);
const query = new URLSearchParams(paths[0]!.split("?")[1] ?? "");
expect(query.get("account")).toBe("p3f9a1");
expect(query.get("provider")).toBe("openai");
expect(query.get("limit")).toBe("5");
});

test("omits the parameter entirely when no account is requested", async () => {
const paths: string[] = [];
const captured = capture();
try {
await handleObserveCommand(["logs"], {
baseUrl: "http://cli.test",
fetchImpl: async input => {
paths.push(String(input).replace("http://cli.test", ""));
return new Response("[]", { status: 200, headers: { "content-type": "application/json" } });
},
});
} finally {
captured.restore();
}
expect(new URLSearchParams(paths[0]!.split("?")[1] ?? "").has("account")).toBe(false);
});

test("human output names the account so a filtered result is distinguishable from an empty one", async () => {
const rows = [
{ id: 1, timestamp: "2026-01-01T00:00:00.000Z", provider: "openai", model: "gpt-test", status: 200, durationMs: 12, accountLogLabel: "p3f9a1" },
{ id: 2, timestamp: "2026-01-01T00:00:01.000Z", provider: "xai", model: "grok-4.6", status: 200, durationMs: 9 },
];
const captured = capture();
try {
await handleObserveCommand(["logs"], {
baseUrl: "http://cli.test",
fetchImpl: async () => new Response(JSON.stringify({ logs: rows }), {
status: 200,
headers: { "content-type": "application/json" },
}),
});
} finally {
captured.restore();
}
expect(captured.lines).toHaveLength(2);
expect(captured.lines[0]).toContain("acct=p3f9a1");
// A provider with a single account stamps no label; the column is absent rather than blank.
expect(captured.lines[1]).not.toContain("acct=");
});
});
1 change: 1 addition & 0 deletions tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@
"cli-models-runtime-dispatch.test.ts": "cli",
"cli-models.test.ts": "cli",
"cli-native-profile.test.ts": "cli",
"cli-observe-logs.test.ts": "cli",
"cli-provider.test.ts": "cli",
"cli-ready-subprocess.test.ts": "cli",
"cli-ready.test.ts": "cli",
Expand Down
38 changes: 38 additions & 0 deletions tests/server/management-api-logs-metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,3 +431,41 @@ describe("GET /api/logs snapshot polling", () => {
}
});
});

/**
* #4057: the account label was persisted on the row and on every attempt long before anything
* could read it back. `requestLogDto` carries it only because it spreads the entry — the sibling
* projection `requestLogEntryFromPersistedUsage` rebuilds field by field and warns in its own
* comment that a field missing there never reaches usage.jsonl. These assertions pin the served
* contract so a future field-by-field rewrite of the DTO cannot drop the label silently.
*/
describe("GET /api/logs account identity", () => {
beforeEach(() => clearRequestLogsForTests());

test("serves the account label on the row and on each attempt, and filters on it", async () => {
addRequestLog(baseEntry({ requestId: "main-row", provider: "openai", accountLogLabel: "main" }));
addRequestLog(baseEntry({
requestId: "pool-row",
provider: "openai",
accountLogLabel: "p3f9a1",
attempts: [
{ ordinal: 1, provider: "openai", model: "gpt-test", adapter: "openai-responses", status: 429, durationMs: 4, sendCount: 1, recoveryKinds: [], usageStatus: "unreported", accountLogLabel: "main" },
{ ordinal: 2, provider: "openai", model: "gpt-test", adapter: "openai-responses", status: 200, durationMs: 6, sendCount: 1, recoveryKinds: [], usageStatus: "reported", accountLogLabel: "p3f9a1" },
],
}));
addRequestLog(baseEntry({ requestId: "unlabelled-row", provider: "xai" }));

const all = await readLogPoll("limit=2000");
const pool = all.logs.find(row => row.requestId === "pool-row")!;
expect(pool.accountLogLabel).toBe("p3f9a1");
expect((pool.attempts as Array<Record<string, unknown>>).map(attempt => attempt.accountLogLabel))
.toEqual(["main", "p3f9a1"]);
expect(all.logs.find(row => row.requestId === "unlabelled-row")!.accountLogLabel).toBeUndefined();

// The pool row is reachable through the account that REFUSED it as well as the one that
// served it, which is what makes the filter usable for quota debugging.
expect((await readLogPoll("account=main")).logs.map(row => row.requestId)).toEqual(["main-row", "pool-row"]);
expect((await readLogPoll("account=p3f9a1")).logs.map(row => row.requestId)).toEqual(["pool-row"]);
expect((await readLogPoll("account=p000000")).logs).toEqual([]);
});
});
33 changes: 33 additions & 0 deletions tests/usage/request-log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,39 @@ describe("request log metadata", () => {
expect(filterRequestLogs(logs, new URLSearchParams("model=grok-4.6&provider=xai")).map(entry => entry.requestId)).toEqual(["b", "c"]);
});

/**
* #4057: the account label was already persisted on every row and every attempt, but nothing
* could select on it, so "which of my accounts served this?" could only be answered by
* grepping usage.jsonl. The non-matching assertion is the one that matters: an implementation
* that ignores `account` entirely passes the positive cases for free.
*/
test("filters logs by account label, including the attempt that actually served a failover", () => {
const logs = [
log({ requestId: "a", provider: "openai", accountLogLabel: "main" }),
log({ requestId: "b", provider: "openai", accountLogLabel: "p3f9a1" }),
log({
requestId: "c",
provider: "openai",
accountLogLabel: "p3f9a1",
attempts: [
{ ordinal: 1, provider: "openai", model: "gpt-test", adapter: "openai", status: 429, durationMs: 5, sendCount: 1, recoveryKinds: [], usageStatus: "unreported", accountLogLabel: "main" },
{ ordinal: 2, provider: "openai", model: "gpt-test", adapter: "openai", status: 200, durationMs: 7, sendCount: 1, recoveryKinds: [], usageStatus: "reported", accountLogLabel: "p3f9a1" },
],
}),
log({ requestId: "d", provider: "xai" }),
];

// "c" matches on its FIRST attempt: the pool account that refused the request is part of
// that account's history, which is exactly what quota debugging needs to see.
expect(filterRequestLogs(logs, new URLSearchParams("account=main")).map(entry => entry.requestId)).toEqual(["a", "c"]);
expect(filterRequestLogs(logs, new URLSearchParams("account=p3f9a1")).map(entry => entry.requestId)).toEqual(["b", "c"]);
// The assertion an unfiltered implementation cannot pass.
expect(filterRequestLogs(logs, new URLSearchParams("account=p000000"))).toEqual([]);
// A row with no label is never swept into an account's history.
expect(filterRequestLogs(logs, new URLSearchParams("account=xai"))).toEqual([]);
expect(filterRequestLogs(logs, new URLSearchParams("account=main&provider=openai")).map(entry => entry.requestId)).toEqual(["a", "c"]);
});

test("filters logs by offset and limit", () => {
const logs = Array.from({ length: 5 }, (_, i) => log({ requestId: `r${i}`, provider: "openai", status: 200 }));
expect(filterRequestLogs(logs, new URLSearchParams("limit=2")).map(entry => entry.requestId)).toEqual(["r3", "r4"]);
Expand Down
Loading