# Call Logs API

> Pull per-call records with the same sk-gpushare-* key — status, failure reason, async task outcome, credits actually charged, and an incremental-sync recipe

来源：https://qianduan.dflop.top/en/docs/reference/logs-api

The per-call ledger behind [logs.dflop.top](https://logs.dflop.top) is now readable with **the same API key** you already use for inference. Typical uses:

- **Sync the call stream into your own system** for reconciliation, reporting or alerting
- Find out **why one call failed** (error code, redacted error text, how many times the gateway re-routed internally)
- Look up **the outcome of an async job** (video / music / speech / image): paste the `id` you got at submit time into `ref`

> Read-only, and it **never costs credits**. A key with a zero balance can still read its logs — which is exactly when you most need to.

| Endpoint | Purpose |
|---|---|
| `GET /v1/logs` | Per-call rows, filtered and paginated |
| `GET /v1/logs/{id}` | Technical view of one row (request params / upstream echo / gateway) |
| `GET /v1/logs/summary` | Aggregates over a window (per day / per model / four terminal states) |

Base URL `https://qianyi.dflop.top`. Auth is the same as the chat endpoints (`Authorization: Bearer sk-gpushare-…` or `x-api-key`) — see [Authentication](./authentication.md).

---

## Visibility (read this first)

**By default a key only sees the calls it made itself.** That is deliberate: a key you handed out should not be able to enumerate the traffic of sibling keys on the same account.

```bash
# Default: only this key's records
curl "https://qianyi.dflop.top/v1/logs?limit=5" \
  -H "Authorization: Bearer $PLATFORM_API_KEY"
```

To pull **every key on the account** in one request, the account owner has to turn it on per key in the console:

> [qianduan.dflop.top → API Keys](https://qianduan.dflop.top/dashboard/keys) → open a key → tick “Let this key read call logs for the whole account”

Then:

```bash
curl "https://qianyi.dflop.top/v1/logs?scope=account&limit=5" \
  -H "Authorization: Bearer $PLATFORM_API_KEY"

# Within account scope you can still narrow to one key
curl "https://qianyi.dflop.top/v1/logs?scope=account&key_id=<uuid>" \
  -H "Authorization: Bearer $PLATFORM_API_KEY"
```

Passing `scope=account` without the toggle returns **403** `permission_denied`.

| Key | `scope=key` (default) | `scope=account` |
|---|---|---|
| Key you created in the console | ✅ | needs the toggle |
| Enterprise-issued employee key | ✅ | ❌ never |
| Compute-package / desktop credential | ✅ | ❌ never |
| Agent key (scoped to `/v1/agent/*`) | ❌ 403 | ❌ 403 |

---

## GET /v1/logs

### Query parameters

| Parameter | Default | Notes |
|---|---|---|
| `scope` | `key` | `key` = this key only; `account` = whole account (needs the toggle) |
| `key_id` | — | `scope=account` only: narrow to one key |
| `period` | `30d` | `today` / `7d` / `30d` / `this_month` / `all`. Ignored when `from`+`to` are given |
| `from` / `to` | — | **Must be given together.** Either `YYYY-MM-DD` or an RFC3339 timestamp; the two forms may be mixed |
| `model` | — | Exact model id, **comma-separated for multi-select**: `model=gpt-5.5,glm-4.7` |
| `status` | — | **Comma-separated**: `success` / `error` / `interrupted` / `rejected` |
| `unit_type` | — | **Comma-separated**: `image` / `video` / `music` / `audio` / `avatar` / `voice` / `search` / `transcript`, plus `chat` (token-billed conversation rows) |
| `error_code` | — | Exact error code, **comma-separated**. Codes are listed in [Errors](./errors.md) |
| `ref` | — | Exact lookup by **task ID or request ID** (both columns are tried — you don't have to know which kind you're holding) |
| `order` | `desc` | `desc` newest first; `asc` oldest first (for incremental pulls) |
| `limit` | `50` | 1–200 |
| `cursor` | — | The `next_cursor` from the previous page, passed back verbatim |
| `include` | `attempts` | Comma-separated: `attempts` (re-route detail) / `in_flight` (unfinished async jobs) / `none` |

> **The upper bound's meaning depends on its form** — this one matters:
> - `to=2026-01-03` (date form) → **includes all of 3 January**
> - `to=2026-01-03T12:00:00Z` (timestamp form) → **excludes that instant** (`created_at < to`)
>
> Use the timestamp form for incremental sync: pass the previous `to` as the next `from` and you get neither gaps nor duplicates.

> **An empty filter value means “don't filter”**, not “match nothing”. `?status=` behaves exactly like omitting `status`.
> A misspelled status (e.g. `?status=failed`) returns **400** rather than silently returning an empty page.

### Response

```jsonc
{
  "object": "list",
  "currency": "points",          // unit of every money field: credits
  "scope": "key",                // the visibility actually applied
  "period_from": "2026-10-08T00:00:00Z",
  "period_to":   "2026-11-07T03:21:00Z",
  "data": [
    {
      "object": "log",
      "id": 90210,
      "created_at":   "2026-11-07T03:18:42Z",  // terminal instant (async: settlement)
      "submitted_at": "2026-11-07T03:17:05Z",  // when the request reached the gateway
      "key_id": "…", "key_name": "prod", "key_prefix": "sk-gpushare-a1b2",
      "model": "doubao-seedance-2.0",
      "status": "success",                      // success|error|interrupted|rejected
      "error_code": null,
      "error_message": null,
      "input_tokens": 0, "output_tokens": 0, "cached_tokens": 0,
      "unit_count": 5, "unit_type": "video",    // per-unit billed row; null = token-billed
      "cost": "182.40",                         // credits charged, a JSON **string**
      "latency_ms": 97210,
      "request_id": "3f9c…",                    // = the x-gateway-trace response header
      "task_id": "9b1e…",                       // the id returned by the async submit call
      "result_urls": ["https://…"],
      "result_expires_at": "2026-11-08T03:18:42Z",
      "attempts_count": 1,                      // null = not queried this round (see below)
      "attempts": [                             // present only with include=attempts
        { "seq": 1, "created_at": "…", "latency_ms": 4100,
          "error_code": "upstream_error", "error_message": "upstream temporarily unavailable" }
      ]
    }
  ],
  "has_more": true,
  "next_cursor": "d:1762..._90210_1759..._1762..."
}
```

**Money is a JSON string** (`"182.40"`), not a number — deliberately, so it never passes through IEEE-754. Parse it as a decimal.

### One row = the terminal state of one external call

- Failed attempts produced by the gateway **automatically re-routing** between upstream lines are **not separate rows, are not billed, and do not count as requests**. They are folded into the terminal row's `attempts[]` (`attempts_count` is how many).
  ⚠️ `attempts_count` is **`null`** when it could not be determined this round — either you didn't ask for `attempts`, or that sub-query itself failed. **Not 0.** Returning 0 would report “we didn't look” as “it definitely never re-routed”, and telling those apart is the whole point of the field.
- Four states: `success` / `error` / `interrupted` (client disconnected or the gateway timed out) / `rejected` — rejected at submit time (bad parameters, model unavailable, insufficient balance, content policy). **Rejected rows always cost 0** but are still recorded, so you can reconcile “sent but never ran”.
- **An async job only becomes a ledger row once it settles.** Jobs still running are returned separately via `include=in_flight` (off by default — that query is markedly more expensive than the list itself). ⚠️ It is returned **only on the first page** (the request without a `cursor`); one copy per pagination run is enough. On later pages the field is **absent entirely**, which stays distinguishable on the wire from “asked for, and nothing is in flight” (an empty array).

### Pagination

```bash
# First page
curl "https://qianyi.dflop.top/v1/logs?period=7d&limit=100" -H "Authorization: Bearer $KEY"
# Follow next_cursor until has_more is false
curl "https://qianyi.dflop.top/v1/logs?cursor=<next_cursor>" -H "Authorization: Bearer $KEY"
```

The cursor is opaque — **pass it back verbatim**. It freezes the window resolved on page 1, so a relative `period=7d` doesn't slide forward mid-run and quietly drop the oldest sliver.

⚠️ The cursor records the sort direction: handing a `order=desc` cursor to an `order=asc` request returns **400** rather than silently giving you a misaligned slice.

---

## Incremental sync recipe

To keep pulling the stream into your own store:

```bash
# Watermark = the instant you last synced through (RFC3339)
WATERMARK="2026-11-07T03:00:00Z"
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)

curl -sS "https://qianyi.dflop.top/v1/logs?from=${WATERMARK}&to=${NOW}&order=asc&limit=200" \
  -H "Authorization: Bearer $PLATFORM_API_KEY"
# After exhausting the pages, advance WATERMARK to NOW
```

**Two things to get right:**

1. **Deduplicate on `id`.** Under concurrency a row's insertion order can differ very slightly from its `created_at` (normal database-sequence behaviour), so this is not a strict change feed. Rewind the watermark by ~5 minutes to overlap and dedupe on `id`. `id` is globally unique and never reused.
2. **Async jobs are booked at their terminal instant (`created_at`), not at submit time.** A video job that ran for 20 minutes appears in the window covering **its settlement**. To align on submit time, use the row's `submitted_at`.

---

## GET /v1/logs/{id}

The **technical view** of one row — what resolution, what duration, what the upstream reported back. `{id}` is the `id` from the list.

```bash
curl "https://qianyi.dflop.top/v1/logs/90210" -H "Authorization: Bearer $KEY"
```

```jsonc
{
  "id": 90210,
  "kind": "video",                 // video|image|music|audio|null (chat / retrieval)
  "request":  [{ "k": "resolution", "v": "1080p" }, { "k": "duration", "v": "5" }],
  "upstream": [{ "k": "framespersecond", "v": "24" }, { "k": "billable_tokens", "v": "…" }],
  "gateway":  [{ "k": "client_protocol", "v": "openai_chat" }, { "k": "request_bytes", "v": "…" }],
  "request_note": null             // why params are empty (e.g. the snapshot was cleared at settlement)
}
```

- Field names are **deliberately untranslated** — they are the names in the API reference, so you can match them line by line.
- All three groups may be empty arrays: chat turns have no task row, older tasks have no upstream snapshot. Empty ≠ error.
- Visibility matches the list: a key without the account toggle asking for another key's row gets **404**.

---

## GET /v1/logs/summary

Window aggregates, for reconciliation.

```bash
curl "https://qianyi.dflop.top/v1/logs/summary?period=this_month" -H "Authorization: Bearer $KEY"
```

```jsonc
{
  "object": "logs.summary",
  "currency": "points",
  "scope": "key",
  "period_from": "…", "period_to": "…",
  "total": { "requests": 1840, "cost": "9123.55",
             "input_tokens": 8812340, "output_tokens": 412990, "cached_tokens": 6610220 },
  "status_counts": { "success": 1802, "error": 21, "interrupted": 4, "rejected": 13 },
  "by_day":   [{ "date": "2026-11-01T00:00:00Z", "requests": 61, "cost": "302.10", "input_tokens": 0, "output_tokens": 0, "cached_tokens": 0 }],
  "by_model": [{ "model": "gpt-5.5", "requests": 1204, "cost": "5120.00", "total_tokens": 7712330 }]
}
```

Three differences from the list endpoint:

- **No row-level filtering.** Passing `model` / `status` / `unit_type` / `error_code` / `ref` returns **400** — this aggregates the *whole* window, and silently ignoring those would hand you the account's totals under a `?model=X` query and let you file them under X. For a per-model breakdown read `by_model[]` in the response; for row-level filtering use `/v1/logs`.
- **The window is capped at 365 days** and **`period=all` is rejected** — leaving it uncapped would starve shared resources. Pull longer histories in segments.
- **Results are cached for 60 seconds** (minute-aligned buckets). A just-settled call may take up to a minute to show up here; for real-time reconciliation use `/v1/logs`, which has no such cache.

`status_counts` excludes internal re-route attempts. A success rate is normally `success / (success + error + interrupted)` — `rejected` calls never reached an upstream at all.

---

## Rate limits

| | |
|---|---|
| Default | **60 requests per minute per key** (shared across all three endpoints) |
| Relationship to inference quota | **Completely separate.** Hammering the logs API will not make your production calls hit 429 |
| Response headers | `x-ratelimit-limit` / `x-ratelimit-remaining` / `x-ratelimit-reset` (seconds) |
| Over the limit | **429** `logs_rate_limited` plus `Retry-After` |

Reconciliation doesn't need high frequency anyway: one call a minute at 200 rows a page keeps up with any realistic volume.

---

## Errors

| Status | `code` | Meaning |
|---|---|---|
| 400 | `invalid_request` | Bad parameters (misspelled status, half-specified range, cursor/order mismatch, unknown `include` token, row-level filters passed to `/summary`) |
| 401 | `invalid_api_key` | Key missing, revoked, expired, or the account is not active |
| 403 | `permission_denied` | `scope=account` without the toggle, or an agent key calling this API |
| 404 | `log_not_found` | No such `id`, or it is outside this key's visibility |
| 429 | `logs_rate_limited` | Over the per-minute limit; retry after `Retry-After` |

Error bodies use the OpenAI shape: `{"error": {"message", "type", "code"}}`. Full reference in [Errors](./errors.md).

---

## Mapping to the web UI / CSV export

The CSV export on [logs.dflop.top](https://logs.dflop.top) is the same data. Only two names differ:

| CSV / web | This API | Why |
|---|---|---|
| `cost_usd` | `cost` | **Both are credits.** The CSV column name predates the platform-wide move off dollars; customer scripts read it by name, so it can't be renamed. The new API doesn't inherit it |
| `logs[]` | `data[]` | Switched to the OpenAI list shape (`object` + `data` + `has_more`) |

Everything else is named and means the same. CSV's `attempts_count` is this API's field of the same name; `result_urls` is `|`-separated in CSV and an array here.

---

## About redaction

`error_message` and `error_code` are redacted before they leave the server: URLs, key material and upstream line names are masked. This does not truncate the diagnosis — the classification (`error_code`) and the human-readable cause are preserved; only our upstream topology is withheld. When reporting a problem, include `request_id` as well.
