Skip to content
Draft
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
57 changes: 57 additions & 0 deletions .github/workflows/otari-dashboard.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Otari Dashboard

on:
push:
branches: [ main, develop ]
paths:
- 'web/**'
- 'src/gateway/static/dashboard/**'
- '.github/workflows/otari-dashboard.yml'
pull_request:
branches: [ main ]
paths:
- 'web/**'
- 'src/gateway/static/dashboard/**'
- '.github/workflows/otari-dashboard.yml'
workflow_dispatch:

jobs:
dashboard:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: web

steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false

- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: 22
cache: npm
cache-dependency-path: web/package-lock.json
Comment on lines +28 to +36

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major

Consider hardening the checkout step to protect the workflow token.

Great work on getting this CI pipeline set up! I noticed a small but important security detail in the checkout step. By default, actions/checkout persists the GITHUB_TOKEN into the local Git configuration. Since the following steps run your build scripts (which can process PR-controlled code), that token is unnecessarily accessible to untrusted input.

Let's tighten that up with a best practice: disable credential persistence and explicitly set read-only permissions for the job. It's a quick tweak that makes the workflow significantly more secure.

Suggested hardening
 jobs:
   dashboard:
     runs-on: ubuntu-latest
+    permissions:
+      contents: read
     defaults:
       run:
         working-directory: web

     steps:
-    - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+    - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+      with:
+        persist-credentials: false
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: 22
cache: npm
cache-dependency-path: web/package-lock.json
jobs:
dashboard:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: 22
cache: npm
cache-dependency-path: web/package-lock.json
🧰 Tools
🪛 zizmor (1.26.1)

[warning] 26-26: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/otari-dashboard.yml around lines 26 - 32, The workflow’s
checkout step is leaving the GitHub token available to later build steps, so
harden the job by disabling credential persistence in the actions/checkout step
and setting the job permissions to read-only where possible. Update the
otari-dashboard workflow around actions/checkout and the job-level permissions
so untrusted build scripts cannot access the persisted token.


- name: Install dependencies
run: npm ci

- name: Type-check
run: npm run typecheck

- name: Test
run: npm test

- name: Build
run: npm run build

- name: Verify committed bundle is up to date
run: |
if ! git diff --quiet -- ../src/gateway/static/dashboard; then
echo "::error::The committed dashboard bundle in src/gateway/static/dashboard is stale."
echo "Run 'npm --prefix web run build' and commit the result."
git --no-pager diff --stat -- ../src/gateway/static/dashboard
exit 1
fi
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ ORM entities are in `src/gateway/models/entities.py` (User, APIKey, Budget, Usag
- If you need a local package build artifact, use: `uv build`
- Docker local build/run: `docker compose up --build`
- CI Docker smoke check is implemented in `scripts/docker_liveness_check.sh`.
## Web Dashboard (`web/`)
- The standalone admin dashboard is a React + HeroUI v3 SPA in `web/` (Vite, Tailwind v4, TanStack Query, Recharts). It calls the existing management API (`/v1/keys`, `/v1/users`, `/v1/usage`) with the master key, entered on a sign-in screen and kept only in the browser tab's session storage.
- Build: `npm --prefix web ci && npm --prefix web run build`. Output goes to `src/gateway/static/dashboard/` (set in `web/vite.config.ts`), which is committed so the wheel and Docker image ship the dashboard with no Node build stage. Rebuild and commit after any change under `web/src`.
- Checks: `npm --prefix web run typecheck`, `npm --prefix web test`. CI runs these and fails if the committed bundle is stale (`.github/workflows/otari-dashboard.yml`).
- Serving: standalone mode serves `index.html` at `/` and hashed assets under `/assets` (`src/gateway/main.py`, `src/gateway/dashboard.py`); the get-started tutorial moved to `/welcome`. Hybrid mode has no local management API, so `/` keeps serving the tutorial. Navigation is client-side section switching, so no server catch-all route is needed.
## Lint / Typecheck Commands
- Ruff is configured for linting (rules: `E`, `F`, `I`; line length: 120) in `pyproject.toml`.
- Run lint checks with `make lint` (or `uv run ruff check src tests scripts`).
Expand Down Expand Up @@ -176,6 +181,8 @@ ORM entities are in `src/gateway/models/entities.py` (User, APIKey, Budget, Usag
- ORM models: `src/gateway/models/entities.py`
- Alembic migrations: `alembic/versions/`
- OpenAPI generator: `scripts/generate_openapi.py`
- Admin dashboard source: `web/` (built bundle committed at `src/gateway/static/dashboard/`)
- Dashboard bundle locator: `src/gateway/dashboard.py`

## Change Validation Checklist
- If you touched API routes or schemas, run relevant integration tests first.
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,16 @@ uv run otari serve --config config.yml

Open API docs at `http://localhost:8000/docs`.

### Admin dashboard

In standalone mode the gateway serves a web admin dashboard at the root URL
(`http://localhost:8000`). Sign in with your master key (`OTARI_MASTER_KEY`) to
create and revoke virtual API keys, manage users, and watch usage and traffic.
The get-started tutorial page moved to `/welcome`. The dashboard is a React +
HeroUI app that lives in `web/`; its built bundle is committed under
`src/gateway/static/dashboard`, so the published package and Docker image serve
it with no extra build step. See [`web/README.md`](web/README.md) to work on it.

## Start in hybrid mode

Hybrid mode connects Otari to [otari.ai](https://otari.ai). It is enabled automatically when `OTARI_AI_TOKEN` is set.
Expand Down
8 changes: 8 additions & 0 deletions deploy/railway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ the snapshot of what the template sets lives in [`template.json`](template.json)
| `OTARI_DATABASE_URL` | `${{Postgres.DATABASE_URL}}` | Pre-wired; leave as-is. |
| `OTARI_MASTER_KEY` | auto-generated (`${{secret(48)}}`) | Auto-set; read it from the otari service's Variables tab. |
| `OTARI_REQUIRE_PRICING` | `false` | Pre-set, so an env-only deploy serves models that have no configured pricing. |
| `OTARI_DEFAULT_PRICING` | `true` | Pre-set, so common models are metered from the bundled genai-prices dataset without configuring each one. Prices you set in the dashboard or via `/v1/pricing` always override it. |
| `OPENAI_API_KEY` | your key | Optional input. Set at least one provider key (see below). |

Notes:
Expand All @@ -51,6 +52,13 @@ Notes:
models instead, supply `pricing` (and any other structured config like custom
`api_base` or Vertex settings) through `OTARI_CONFIG_YAML` / `OTARI_CONFIG_B64`;
see [Full config via environment](../../docs/configuration.md#full-config-via-environment).
- `OTARI_DEFAULT_PRICING=true` is also pre-set so that, paired with the above,
common models are metered using community-maintained rates (the bundled
genai-prices dataset) instead of being served unpriced. These are estimates
and can lag real provider rates, so set explicit prices in the dashboard
(Pricing page) or via `/v1/pricing` for anything you bill on; database prices
always win over the fallback. The dashboard's Pricing page shows whether this
fallback is active.

## Deploy

Expand Down
5 changes: 5 additions & 0 deletions deploy/railway/template.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@
"isOptional": false,
"description": "Set to false so an env-only deploy serves models that have no configured pricing. The image default is true (fail-closed), which would reject every model until pricing is configured."
},
"OTARI_DEFAULT_PRICING": {
"defaultValue": "true",
"isOptional": false,
"description": "Fall back to community-maintained default pricing (the bundled genai-prices dataset) for models you have not priced explicitly. The image default is false; pre-set to true here so an env-only deploy meters common models out of the box. Prices you set in the dashboard or via /v1/pricing always override the fallback."
},
"OPENAI_API_KEY": {
"defaultValue": "",
"isOptional": true,
Expand Down
142 changes: 142 additions & 0 deletions docs/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,50 @@
"title": "AudioSpeechRequest",
"type": "object"
},
"BackfillCostRequest": {
"description": "Request to backfill cost on previously-unpriced usage rows.",
"properties": {
"model_key": {
"description": "Model to backfill, as 'provider:model' (or a bare model name when no provider was recorded).",
"title": "Model Key",
"type": "string"
}
},
"required": [
"model_key"
],
"title": "BackfillCostRequest",
"type": "object"
},
"BackfillCostResponse": {
"description": "Summary of a usage-cost backfill.",
"properties": {
"cost_added": {
"title": "Cost Added",
"type": "number"
},
"model_key": {
"title": "Model Key",
"type": "string"
},
"rows_updated": {
"title": "Rows Updated",
"type": "integer"
},
"users_updated": {
"title": "Users Updated",
"type": "integer"
}
},
"required": [
"model_key",
"rows_updated",
"cost_added",
"users_updated"
],
"title": "BackfillCostResponse",
"type": "object"
},
"BatchRequestItem": {
"properties": {
"body": {
Expand Down Expand Up @@ -1012,6 +1056,35 @@
"title": "EmbeddingRequest",
"type": "object"
},
"GatewaySettings": {
"description": "Operator-facing runtime settings surfaced to the dashboard.",
"properties": {
"default_pricing": {
"title": "Default Pricing",
"type": "boolean"
},
"mode": {
"title": "Mode",
"type": "string"
},
"require_pricing": {
"title": "Require Pricing",
"type": "boolean"
},
"version": {
"title": "Version",
"type": "string"
}
},
"required": [
"mode",
"version",
"default_pricing",
"require_pricing"
],
"title": "GatewaySettings",
"type": "object"
},
"GuardrailConfig": {
"description": "A single guardrail check the caller wants the gateway to enforce.\n\nURL safety: when ``url`` is supplied it is validated at parse time with the\nsame SSRF guard used for MCP server URLs (loopback allowed by default for\nsame-host sidecars; gated by ``OTARI_MCP_ALLOW_LOOPBACK`` /\n``OTARI_MCP_ALLOW_PRIVATE_HOSTS``). Most deployments omit ``url`` and rely\non the operator-set ``OTARI_GUARDRAILS_URL`` instead.",
"properties": {
Expand Down Expand Up @@ -1581,6 +1654,11 @@
"type": "null"
}
]
},
"pricing_source": {
"default": "none",
"title": "Pricing Source",
"type": "string"
}
},
"required": [
Expand Down Expand Up @@ -4757,6 +4835,28 @@
]
}
},
"/v1/settings": {
"get": {
"description": "Return non-secret runtime settings for the admin dashboard.",
"operationId": "get_settings_v1_settings_get",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GatewaySettings"
}
}
},
"description": "Successful Response"
}
},
"summary": "Get Settings",
"tags": [
"settings"
]
}
},
"/v1/usage": {
"get": {
"description": "List usage logs ordered by timestamp (most recent first).\n\nSupports optional filters for time range and user. Paginated via skip/limit.\nTimestamps accept either ISO 8601 strings or Unix epoch seconds (numeric).",
Expand Down Expand Up @@ -4874,6 +4974,48 @@
]
}
},
"/v1/usage/backfill": {
"post": {
"description": "Recompute cost for a model's usage rows that were logged without one.\n\nFor usage rows of ``model_key`` whose ``cost`` is null (they ran while the\nmodel was unpriced), compute cost from the recorded token counts and the\nmodel's current price, then add each user's backfilled total to their spend.\nRows that already have a cost are left untouched, so this is safe to re-run.\nRequires master key authentication.",
"operationId": "backfill_usage_cost_v1_usage_backfill_post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BackfillCostRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BackfillCostResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Backfill Usage Cost",
"tags": [
"usage"
]
}
},
"/v1/users": {
"get": {
"description": "List all users with pagination.",
Expand Down
56 changes: 56 additions & 0 deletions docs/public/otari.postman_collection.json
Original file line number Diff line number Diff line change
Expand Up @@ -1312,6 +1312,29 @@
],
"name": "responses"
},
{
"item": [
{
"name": "Get Settings",
"request": {
"description": "Return non-secret runtime settings for the admin dashboard.",
"header": [],
"method": "GET",
"url": {
"host": [
"{{baseUrl}}"
],
"path": [
"v1",
"settings"
],
"raw": "{{baseUrl}}/v1/settings"
}
}
}
],
"name": "settings"
},
{
"item": [
{
Expand Down Expand Up @@ -1363,6 +1386,39 @@
"raw": "{{baseUrl}}/v1/usage?start_date=&end_date=&user_id=&skip=&limit="
}
}
},
{
"name": "Backfill Usage Cost",
"request": {
"body": {
"mode": "raw",
"options": {
"raw": {
"language": "json"
}
},
"raw": "{\n \"model_key\": \"string\"\n}"
},
"description": "Recompute cost for a model's usage rows that were logged without one.\n\nFor usage rows of ``model_key`` whose ``cost`` is null (they ran while the\nmodel was unpriced), compute cost from the recorded token counts and the\nmodel's current price, then add each user's backfilled total to their spend.\nRows that already have a cost are left untouched, so this is safe to re-run.\nRequires master key authentication.",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"method": "POST",
"url": {
"host": [
"{{baseUrl}}"
],
"path": [
"v1",
"usage",
"backfill"
],
"raw": "{{baseUrl}}/v1/usage/backfill"
}
}
}
],
"name": "usage"
Expand Down
8 changes: 7 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,13 @@ where = ["src"]
include = ["gateway*"]

[tool.setuptools.package-data]
gateway = ["templates/*.html", "templates/*.svg"]
gateway = [
"templates/*.html",
"templates/*.svg",
"static/dashboard/index.html",
"static/dashboard/favicon.svg",
"static/dashboard/assets/*",
]

[tool.ruff]
target-version = "py313"
Expand Down
Loading
Loading