#!/usr/bin/env python3 """Render docs/openai/admin-api.md from the generated admin fragments (tables) + curated prose.""" from __future__ import annotations import json from collections import OrderedDict from pathlib import Path ROOT = Path(__file__).resolve().parents[2] E = json.loads((ROOT / "generated/fragments/endpoints/openai-admin.json").read_text()) P = json.loads((ROOT / "generated/fragments/parameters/openai-admin.json").read_text()) A = json.loads((ROOT / "generated/fragments/audit-log-events/openai-audit-log-events.json").read_text()) def group_of(path: str) -> str: p = path.replace("/v1/", "") if p.startswith("organization/usage") or p == "organization/costs": return "Usage & costs" if "/certificates" in p: return "Certificates (Mutual TLS)" if p.startswith("organization/projects/{project_id}/"): sub = p.split("/")[3] return "Project " + sub.replace("_", " ") if p.startswith("projects/"): return "Project roles & role bindings" if p.startswith("organization/users/{user_id}/roles") or p.startswith("organization/groups/{group_id}/roles"): return "Organization role bindings" if p.startswith("organization/groups/{group_id}/users"): return "Group membership" return "Organization " + p.split("/")[1].replace("_", " ") groups: "OrderedDict[str, list]" = OrderedDict() for e in E: groups.setdefault(group_of(e["path"]), []).append(e) def st(e): s = e["status"] return "ACCOUNT_RESTRICTED (403/401 with project key)" if "ACCOUNT_RESTRICTED" in s else ", ".join(s) def params_for(e): eid = f"{e['method']} {e['path']}" ps = [p for p in P if p["endpoint"] == eid and p["location"] in ("query", "body") and "." not in p["parameter"] and "[]" not in p["parameter"]] out = [] for p in ps: tag = "*" if p["required"] else "" extra = f" ({'|'.join(map(str, p['enum']))})" if p.get("enum") and len(p["enum"]) <= 8 else "" out.append(f"`{p['parameter']}`{tag}{extra}") return ", ".join(out) or "—" lines = [] w = lines.append w("# OpenAI Administration API — atlas\n") w("**Status:** `DOCUMENTED` for all 124 operations (OpenAPI spec v2.3.0); every GET we could probe is `ACCOUNT_RESTRICTED` (our project key gets HTTP 403 — 401 for audit logs — `Missing scopes: api.management.read` etc.). No Admin key is available, so nothing below is `LIVE_VERIFIED` except the exact error shapes. No mutating admin call was ever issued.\n") w("**Sources:** [Administration overview](https://developers.openai.com/api/reference/administration/overview) · [Admin APIs guide](https://developers.openai.com/api/docs/guides/admin-apis) · [Production best practices → organization](https://developers.openai.com/api/docs/guides/production-best-practices#setting-up-your-organization) · [Spend limits](https://developers.openai.com/api/docs/guides/spend-limits) · [Mutual TLS](https://developers.openai.com/api/docs/guides/mutual-tls) · [IP allowlist](https://developers.openai.com/api/docs/guides/ip-allowlist) · [Your data (retention / residency)](https://developers.openai.com/api/docs/guides/your-data) · OpenAPI `sources/openai/openapi/openapi-master.yaml` (all `/organization/*` + `/projects/*` paths) · SDK surfaces `sources/openai/openapi/{python,node}-sdk-api.md`.\n") w("**Last verified:** 2026-09-18 (live probes: 14 admin GETs, all 401/403; details in `tmp-live/openai-admin/` and `reports/live-requests.jsonl`).\n") w("Machine-readable twins: `generated/fragments/endpoints/openai-admin.json` (124 endpoints), `generated/fragments/parameters/openai-admin.json` (431 parameters), `generated/fragments/audit-log-events/openai-audit-log-events.json` (149 event types).\n") w(""" ## 1. Model of the Admin API | Concept | What it is | Where | |---|---|---| | Organization | Billing + policy boundary; has users (owner/reader), groups, custom roles, spend limit, data retention, certificates, external storage, audit log | `/v1/organization/*` | | Project | Isolation unit inside an org: own API keys, service accounts, users (owner/member), groups, per-model rate limits, model/hosted-tool permissions, spend limit & alerts, data retention, certificates, webhooks | `/v1/organization/projects/{project_id}/*`, project roles at `/v1/projects/{project_id}/*` | | Admin API key | `sk-admin-…` created by org owners at *Settings → Organization → Admin keys*; the only credential accepted by `/v1/organization/*` and `/v1/projects/*`; refused by non-admin endpoints. Workload-identity tokens **cannot** call Admin endpoints. | `GET/POST/DELETE /v1/organization/admin_api_keys` | | Scopes | Fine-grained permissions carried by restricted keys / roles. Read scopes we observed in error messages: `api.management.read` (users, invites, projects, admin keys, spend limit/alerts, data retention), `api.usage.read` (usage + costs), `api.audit_logs.read`, `api.roles.read`, `api.groups.read`, `api.mtls.read` (certificates, message adds "(Owner)"), `api.external_storage.read`. Documented write scopes: `api.groups.write`, `api.mtls.write`. Model-side scopes seen in the spec/guides: `api.model.request`, `api.model.read`, `api.responses.write`, `api.files.read/write`, `api.batch.read/write`, `api.vector_store.read`, `api.videos.read/write`, `api.voices.read/write`, `api.agents.read/write`, `api.vaults.read/write`, `api.webhooks.read`, `api.traces.read`, `api.safety.read`, `api.safety.alerts.read`, `api.apps.read/write`. | error bodies, Role.permissions[] | | Roles | Predefined + custom roles (`object: role`, `permissions[]`, `resource_type: api.organization | api.project`, `predefined_role`). Bound to users or groups at org level (`/organization/users/{id}/roles`, `/organization/groups/{id}/roles`) or project level (`/projects/{id}/users/{uid}/roles`, `/projects/{id}/groups/{gid}/roles`). Legacy simple roles: org `owner` / `reader`; project `owner` / `member`. | `/v1/organization/roles`, `/v1/projects/{id}/roles` | | Pagination | Cursor style: `limit` (1–100, default 20) + `after` → `{object:"list", data, first_id, last_id, has_more}`. Usage/costs use `page` → `{object:"page", data:[buckets], has_more, next_page}`. Roles/groups lists use `after`/`limit` and return `has_more` + `next` cursor (SDK `SyncNextCursorPage`). | — | | Mutations | Always `POST` (no PUT/PATCH), deletes are `DELETE` → `{object:"…deleted", id, deleted:true}`. No Idempotency-Key header. | — | ## 2. Endpoint catalogue Legend: `*` = required parameter; SDK column = Python method (Node is the camelCase twin, e.g. `client.admin.organization.auditLogs.list`). """) for g, eps in groups.items(): w(f"\n### {g}\n") w("| Method | Path | Name | Key params (query/body) | Python SDK | Status |") w("|---|---|---|---|---|---|") for e in eps: sdk = (e["sdk"]["python"] or "—").split("(")[0] w(f"| `{e['method']}` | `{e['path']}` | {e['name']} | {params_for(e)} | `{sdk}` | {st(e)} |") w(""" ## 3. Roles & scopes catalogue | Level | Predefined roles (legacy strings) | Where they appear | |---|---|---| | Organization | `owner` (billing, members, all reader rights), `reader` (make API requests, view org info, manage resources) | `Invite.role`, `OrganizationUser.role`, `POST /organization/users/{id}` body `role` | | Project | `owner`, `member` | `ProjectUser.role`, `Invite.projects[].role`, `POST /organization/projects/{id}/users` | | Custom roles | `Role{id, name, description, permissions[], resource_type, predefined_role}` created with `POST /organization/roles` (org-wide) or `POST /projects/{id}/roles`; assigned with the `…/roles` binding endpoints (`role_id`) | Roles API | | API key permission levels (dashboard) | *All*, *Restricted* (per-resource read/write), *Read-only*; restricted keys carry scopes like `api.model.request`; audit log `api_key.created.data.scopes[]` records them | dashboard + audit logs | | Key types | project key `sk-proj-…` (owned by a user or a **service account**, `owner.type: user|service_account`, `owner_project_access: active|inactive`), legacy user key, service-account key (`POST /organization/projects/{p}/service_accounts` returns `api_key.value` once, or `POST …/service_accounts/{id}/api_keys` to mint another with optional `expires_at`), Admin key `sk-admin-…`, WIF short-lived bearer | see `docs/openai/authentication-and-keys.md` | Governance knobs documented in production-best-practices: max API-key lifetime (org and project, project ≤ org), *API Key Governance* (allow only service-account keys / only user keys / block new keys; org restrictions win), key usage tracking (keys created before 2023-12-20 are untracked by default). ## 4. Usage & costs query cookbook (`ACCOUNT_RESTRICTED` for us — verified 403 `api.usage.read`) All usage endpoints share the same query grammar (from the OpenAPI spec): | Param | Type | Notes | |---|---|---| | `start_time`* | int (unix s) | inclusive | | `end_time` | int | exclusive | | `bucket_width` | `1m` / `1h` / `1d` (default `1d`) | costs: only `1d` | | `limit` | int | buckets per page: `1d` ≤ 31 (default 7), `1h` ≤ 168 (default 24), `1m` ≤ 1440 (default 60) | | `page` | string | cursor from `next_page` | | `project_ids`, `user_ids`, `api_key_ids`, `models` | string[] | filters (repeat the query key) | | `batch` | bool | completions/embeddings/…: true = Batch API traffic only | | `group_by` | string[] | completions: `project_id, user_id, api_key_id, model, batch, service_tier`; costs: `project_id, line_item`; images add `size, source`; audio speeches/transcriptions add `model`; code_interpreter_sessions/vector_stores: `project_id` | | Endpoint | Result object | Key metrics | |---|---|---| | `GET /v1/organization/usage/completions` | `organization.usage.completions.result` | `input_tokens, input_cached_tokens, input_cache_write_tokens, input_uncached_tokens, output_tokens, input/output_text|audio|image_tokens, num_model_requests`, dims `project_id, user_id, api_key_id, model, batch, service_tier` | | `…/usage/embeddings` | `organization.usage.embeddings.result` | `input_tokens, num_model_requests` | | `…/usage/moderations` | `organization.usage.moderations.result` | `input_tokens, num_model_requests` | | `…/usage/images` | `organization.usage.images.result` | `images, num_model_requests`, dims `size` (`256x256`…`1536x1024`), `source` (`image.generation, image.edit, image.variation`) | | `…/usage/audio_speeches` | `organization.usage.audio_speeches.result` | `characters, num_model_requests` | | `…/usage/audio_transcriptions` | `organization.usage.audio_transcriptions.result` | `seconds, num_model_requests` | | `…/usage/vector_stores` | `organization.usage.vector_stores.result` | `usage_bytes` | | `…/usage/code_interpreter_sessions` | `organization.usage.code_interpreter_sessions.result` | `num_sessions` | | `…/usage/file_search_calls`, `…/usage/web_search_calls` | `organization.usage.*_calls.result` | `num_calls` (web search adds `search_context_size`) | | `GET /v1/organization/costs` | `organization.costs.result` | `amount{value, currency:"usd"}, line_item, project_id, organization_id` | Recipes (curl; `-G` turns `--data-urlencode` into query params): ```bash # daily completions tokens per model for the last 7 days curl -G https://api.openai.com/v1/organization/usage/completions -H "Authorization: Bearer $OPENAI_ADMIN_KEY" \\ --data-urlencode "start_time=$(( $(date +%s) - 7*86400 ))" --data-urlencode bucket_width=1d \\ --data-urlencode group_by=model --data-urlencode group_by=service_tier --data-urlencode limit=7 # hourly usage of one project, Batch only, paginated curl -G https://api.openai.com/v1/organization/usage/completions -H "Authorization: Bearer $OPENAI_ADMIN_KEY" \\ --data-urlencode start_time=1789000000 --data-urlencode bucket_width=1h --data-urlencode project_ids=proj_XXX \\ --data-urlencode batch=true --data-urlencode limit=168 --data-urlencode page=page_AAAA… # spend in USD per day per line item and project curl -G https://api.openai.com/v1/organization/costs -H "Authorization: Bearer $OPENAI_ADMIN_KEY" \\ --data-urlencode start_time=$(( $(date +%s) - 30*86400 )) --data-urlencode bucket_width=1d \\ --data-urlencode group_by=line_item --data-urlencode group_by=project_id --data-urlencode limit=31 ``` Python: `client.admin.organization.usage.completions(start_time=…, bucket_width="1d", group_by=["model"], limit=7)`; costs: `client.admin.organization.usage.costs(...)`. Runnable examples: `examples/openai/admin/usage_costs.sh`, `admin_readonly.py`, `admin_readonly.ts` (all return 403 on our account). ## 5. Spend limits, alerts, data retention, permissions | Resource | Endpoints | Body / semantics | |---|---|---| | Org spend limit | `GET/POST/DELETE /v1/organization/spend_limit` | `threshold_amount` (cents), `currency: USD`, `interval: month`; hitting it → `429 organization_spend_limit_exceeded` on API traffic | | Project spend limit | `GET/POST/DELETE /v1/organization/projects/{p}/spend_limit` | same body; → `429 project_spend_limit_exceeded` | | Spend alerts | `/v1/organization/spend_alerts[/{id}]`, `/v1/organization/projects/{p}/spend_alerts[/{id}]` | `threshold_amount`, `currency`, `interval`, `notification_channel{type:"email", recipients[], subject_prefix}` | | Data retention | `GET/POST /v1/organization/data_retention`, `GET/POST /v1/organization/projects/{p}/data_retention` | project `retention_type: organization_default` inherits; org sets the policy (ZDR / modified retention need approval) | | Model permissions | `GET/POST/DELETE /v1/organization/projects/{p}/model_permissions` | `mode: allow_list | deny_list`, `model_ids[]` (must be visible to the org, fine-tuned snapshots allowed); denied models return `404 model_not_found` | | Hosted tool permissions | `GET/POST /v1/organization/projects/{p}/hosted_tool_permissions` | allow/deny hosted tools (web search, code interpreter, file search, MCP, …) per project | | Rate limits | `GET /v1/organization/projects/{p}/rate_limits`, `POST …/rate_limits/{rate_limit_id}` | `project.rate_limit{model, max_requests_per_1_minute, max_tokens_per_1_minute, max_images_per_1_minute, max_audio_megabytes_per_1_minute, max_requests_per_1_day, batch_1_day_max_input_tokens}`; a project limit can only lower the org limit | | Certificates (mTLS) | org: `GET/POST /v1/organization/certificates`, `…/{id}` GET/POST/DELETE, `…/activate`, `…/deactivate`; project: `GET …/projects/{p}/certificates`, `…/activate`, `…/deactivate` | upload PEM trust anchor (`content`, `name`), activate = enforce; API mTLS host `mtls.api.openai.com`; scopes `api.mtls.read/write` | | External storage | `GET/POST /v1/organization/external_storage`, `GET/DELETE …/{id}`, `POST …/{id}/validate` | bring-your-own bucket for stored artifacts; audit events `external_storage.registered/removed` | | Archive project | `POST /v1/organization/projects/{p}/archive` | irreversible; `status: archived` | ## 6. Audit logs `GET /v1/organization/audit_logs` (scope `api.audit_logs.read`; **HTTP 401** not 403 without it). Filters: `effective_at[gt|gte|lt|lte]`, `project_ids[]`, `event_types[]`, `actor_ids[]`, `actor_emails[]`, `resource_ids[]`, `limit`, `after`, `before`. Each entry: `{object:"organization.audit_log", id, type, effective_at, project{id,name}, actor{type: session|api_key, session{user, ip_address, user_agent, ja3, ja4, ip_address_details}, api_key{type: user|service_account, id, user|service_account}}, : {payload}}`. """) cats = OrderedDict() for a in A: cats.setdefault(a["category"], []).append(a["event"]) w("| Category | Event types (149 total) |") w("|---|---|") for c, evs in cats.items(): w(f"| {c} | " + ", ".join(f"`{e}`" for e in evs) + " |") w("\nPayload fields per event are in `generated/fragments/audit-log-events/openai-audit-log-events.json` (57 events carry a typed payload in the spec; the `tenant.*` enterprise-workspace events are enum-only).\n") w(""" ## 7. Verified behaviour with a non-admin key (2026-09-18) | Call | HTTP | Body | |---|---|---| | `GET /v1/organization/{users,invites,projects,admin_api_keys,spend_limit,spend_alerts,data_retention}` | 403 | `{"error": "You have insufficient permissions for this operation. Missing scopes: api.management.read. Check that you have the correct role in your organization, and if you're using a restricted API key, that it has the necessary scopes."}` — note `error` is a **bare string** | | `GET /v1/organization/usage/completions`, `GET /v1/organization/costs` | 403 | same shape, `api.usage.read` | | `GET /v1/organization/roles` / `groups` | 403 | `api.roles.read` / `api.groups.read` | | `GET /v1/organization/certificates` | 403 | `api.mtls.read … correct role in your organization (Owner)` | | `GET /v1/organization/external_storage` | 403 | structured: `{"error":{"message":"… api.external_storage.read …","type":"invalid_request_error","param":null,"code":"insufficient_permissions"}}` | | `GET /v1/organization/audit_logs` | **401** | structured, `api.audit_logs.read`, `code: null` | Response headers on these errors still include `x-request-id`, `openai-version: 2020-10-01`, `openai-processing-ms`, and (except certificates/audit logs) `openai-organization` / `openai-project`. ## 8. SDK access ```python from openai import OpenAI client = OpenAI(admin_api_key=os.environ["OPENAI_ADMIN_KEY"]) # Python >= 2.34.0 users = client.admin.organization.users.list(limit=20) # SyncConversationCursorPage -> iterate for u in users: print(u.id, u.email, u.role) client.admin.organization.projects.model_permissions.update("proj_abc", mode="allow_list", model_ids=["gpt-4.1"]) ``` ```ts const client = new OpenAI({ adminAPIKey: process.env.OPENAI_ADMIN_KEY }); // Node >= 6.36.0 for await (const log of client.admin.organization.auditLogs.list({ limit: 50 })) console.log(log.type); ``` Go `option.WithAdminAPIKey` (≥ 3.34.0), Java `OpenAIOkHttpClient.builder().adminApiKey(...)` (≥ 4.34.0), Ruby `OpenAI::Client.new(admin_api_key:)` (≥ 0.61.0). """) (ROOT / "docs/openai/admin-api.md").write_text("\n".join(lines)) print("wrote docs/openai/admin-api.md", len("\n".join(lines)), "chars;", len(E), "endpoints in", len(groups), "groups")