Ploy
Ploy
AI

Delegated AI credentials

Issue short-lived model-scoped credentials to tasks without sharing project credentials or platform signing keys.

Delegated AI credentials

A trusted service can exchange its existing project AI Bearer token for a short-lived task credential through Ploy's built-in broker. This is a real platform endpoint, not an application-supplied signing interface. Only the delegated token belongs in the task or sandbox. Keep the parent project token on the trusted service, and never send PLOY_AI_TOKEN_SECRET or upstream provider keys to customer workers, browsers, or sandboxes.

Provision and manage through the CLI

Use your existing ploy auth login platform login as an owner or admin of the exact organization and project. No new signing secret or provider key is required, and no billing plan or credits are changed automatically.

ploy api ai configure project-example --org org-example --enabled true --json
ploy api ai models project-example --org org-example --json
ploy api ai delegate project-example --org org-example \
  --tenant tenant-example --task task-example --models provider/model \
  --ttl-seconds 60 --max-requests 1 --json
ploy api ai revoke project-example grant-example --org org-example --yes --json

models returns the actual upstream catalog without inference. Choose an explicit available low-cost model from that catalog; no fallback model is selected. Enabling AI requires an already eligible paid organization; it never upgrades or funds an account. Keep ai: true in ploy.yaml for subsequent deployments: a build's configuration can update the project flag, and existing deployment-resolution caches may delay an operator flag change.

delegate outputs a sensitive task-only token only when --json is supplied. Protect stdout and pipe it only to your authorized task credential handoff; do not paste tokens into logs, issues or shell history. Without --json, the token is hidden. The default requested lifetime is 600 seconds, also capped by the operator session's remaining lifetime. Request/rate integers are strictly validated, and both rate options must be supplied together. Revocation requires --yes.

The authenticated management API is:

  • POST /orgs/{orgId}/projects/{projectId}/ai/delegations, with the task scope below and optional deploymentId. Response: {token, grantId, expiresAt}; Unix milliseconds. Unknown scope overrides are rejected.
  • POST /orgs/{orgId}/projects/{projectId}/ai/delegations/{grantId}/revoke, body { "confirmation": "REVOKE" }.
  • PATCH /orgs/{orgId}/projects/{projectId}/ai/configuration, body { "enabled": true } or false.
  • GET /orgs/{orgId}/projects/{projectId}/ai/models, with no request body or inference.

All four require the existing platform login/session and owner/admin membership, with exact organization/project ownership. An optional deployment must belong to that project. Responses are not cacheable; no endpoint exports a broad project credential. Revocation remains available when project AI is disabled or its plan is no longer eligible.

Unattended trusted-service delegation

A direct CLI task grant is useful for a bounded smoke test, not a renewable bootstrap credential for an unattended service. Deploy the trusted service with ai: true; Ploy supplies a project/deployment-scoped AI capability without a user-managed signing secret. The generic binding calls the same platform broker through the private AI service:

const grant = await env.AI.delegate({
	tenantId: authorizedTenant.id,
	taskId: task.id,
	models: [selectedCatalogModel],
	ttlSeconds: 600,
	maxRequests: 20,
	rate: { requests: 5, windowSeconds: 60 },
});
// Hand off only grant.token to this task, never the parent worker credential.
// On completion/cancellation, revoke future admissions explicitly:
await env.AI.revoke(grant.grantId);

The trusted service authorizes task ownership before each mint or renewal and keeps grant IDs for revocation. A later renewal creates a new grant, not a child of the old task token. Keep the project capability in the trusted worker binding; task sandboxes must suppress broad automatic project-token injection and receive only their bounded task credential. Do not use a platform CLI session as the service's runtime credential.

The handler's cached worker identity now includes a 30-minute AI credential epoch, shorter than the default one-hour project credential lifetime. New requests load a fresh isolate and platform-minted capability after the epoch boundary. This changes neither task IDs nor durable workflow state and does not replay, retry or cancel customer requests. Existing in-flight work is not restarted; a worker retaining its old environment for over an hour can still reach that environment's token expiry and must surface the failure rather than replay inference.

The raw escape hatch also remains available to the trusted worker: POST http://ai/delegate with its injected PLOY_AI_TOKEN. env.AI.delegate/revoke avoid manual URL/header assembly and use the private scoped service; the local emulator uses the same client logic with its configured AI endpoint. Neither facade returns a signing secret or broad project token.

Issue a credential

Call POST /ai/delegate on the public Ploy API (or the internal AI service from a trusted platform binding), using Authorization: Bearer <project AI token> and a JSON object:

{
	"tenantId": "tenant-example",
	"taskId": "task-example",
	"models": ["provider/model"],
	"ttlSeconds": 600,
	"maxRequests": 20,
	"rate": { "requests": 5, "windowSeconds": 60 }
}
  • tenantId and taskId: required nonempty strings, at most 256 characters. The trusted issuer must derive these from its authorized task, not forward unvalidated sandbox input.
  • models: 1–100 exact model IDs, each at most 256 characters. The entire serialized UTF-8 delegation scope must fit in 4,096 bytes; oversized scopes are rejected before issuance so the Bearer token fits ordinary HTTP header limits. No wildcard expansion or normalization. An alias such as auto authorizes that upstream alias and its routing; choose explicit IDs when tasks must not use automatic routing.
  • ttlSeconds: required integer, 1–600 seconds. Expiry is the earlier of the requested lifetime and the parent token's expiry.
  • maxRequests: optional positive integer, at most 2,147,483,647. Omission means no total request limit within the short lifetime.
  • rate: optional fixed-window limit, with positive integer requests (at most 2,147,483,647) and integer windowSeconds from 1–600. Windows begin on the first accepted request and reset on an admission after the window ends.

Response (200):

{
	"token": "opaque-signed-token",
	"grantId": "platform-generated-grant-id",
	"expiresAt": 1790000000000
}

expiresAt is Unix time in milliseconds. Treat the token as opaque: it retains Ploy's existing signed two-part HMAC format, not a JWT. The platform signs the exact project, optional deployment, task, tenant, model list, limits and expiry. Request-supplied project/deployment/expiry fields cannot override the parent. Existing project AI credentials keep their original behavior.

Delegated tokens cannot issue child credentials or revoke grants. Management endpoints accept only owner project Bearer credentials, not query-token or x-api-key authentication. Project AI enablement, paid-plan eligibility, project/organization deletion and deployment ownership are checked for management and inference requests.

Call the gateway

Supply the delegated token as Authorization: Bearer <token> (or the inference protocol's x-api-key) on the Ploy AI endpoint. Each request must be JSON, at most 16 MiB, and contain a top-level model matching the signed allowlist.

Allowed routes are POST /ai/run, /ai/v1/chat/completions, /ai/v1/messages, /ai/v1/responses, /ai/v1/embeddings, and /ai/v1/images/generations. /ai/run forwards to chat completions. Delegated GET/model-catalog, provider configuration, header-selected provider protocol routes, and administrative routes are denied. ai-language-model-id overrides are denied for delegated requests.

Responses remains stateless: the gateway forces store: false; rejects background jobs, conversation/previous-response links, stored item/file references, and provider-hosted tools; and allows client-executed function/custom tools. Retrieval and deletion of shared upstream responses are unavailable. Streaming continues without buffering the upstream completion.

The gateway discards incoming x-ploy-*, x-llmgateway-*, x-tenant-id, x-task-id, x-project-id, x-deployment-id, x-grant-id and x-organization-id headers. Attribution comes from verified token claims, never spoofable request headers. Provider/model attribution still comes from the upstream usage report. Tenant/task/grant IDs accompany usage events and are persisted in existing billing and deployment-live-log JSON metadata without storing prompts or credentials. Usage delivery retains the existing Redis queue and database fallback; simultaneous failure of both can still lose an event and is logged, not an exact spend guarantee.

Revoke a credential

Call POST /ai/delegate/revoke with an owner project AI Bearer token:

{ "grantId": "platform-generated-grant-id" }

A matching grant returns 200 { "revoked": true }; repeating revocation is safe. An unknown or foreign grant returns 404. Project-only credentials may revoke that project's grants; a deployment-scoped owner may revoke only grants for that same project and deployment.

Revocation is durable in PostgreSQL and checked on every new request, across gateway replicas and process restarts. There is no local allow-cache or stateless revocation substitute. Missing, expired, mismatched or revoked grants fail closed (403); atomic quota/rate admission failures return 429, including a revoke/expiry race after the initial check. Store outages fail closed (5xx). Accepted in-flight inference is not terminated by later revocation.

Replay and operational limits

Tokens are bearer capabilities: possession allows repeated calls until expiry, revocation or exhaustion. This is not proof-of-possession or single-use authentication. Optional maxRequests: 1 bounds admission to one upstream attempt, but does not provide idempotent retrieval of its result.

Every accepted attempt consumes the atomic shared PostgreSQL request/rate reservation before upstream fetch, including transport errors, cancellation and provider errors. No refunds, automatic prompt replay, or implicit retries are added. A client retry is a new attempt and may incur another charge. Limits count requests, not dollars or tokens; model prices and request size vary. Explicitly revoke on task completion and on cancellation; keep the bounded lifetime as a fallback when the caller crashes before cleanup.

Deployment requires the generated ai_delegation migration and a configured platform signing secret using the existing production secret checks. Issuance fails if persistence is unavailable; credentials are never knowingly returned without a durable grant. Expired rows currently remain for operational retention; no automatic pruning job is introduced here. No production configuration or migration is applied by this implementation.

Verification

From the repository root, rebuild dependency packages before integration tests:

pnpm exec turbo run build --filter=@meetploy/tools --cache=local:rw
pnpm exec vitest run packages/shared/src/ai-token.spec.ts packages/shared/src/ai-delegation.spec.ts apps/api/test/ai-gateway.spec.ts apps/api/test/ai-delegation.spec.ts packages/tools/src/ai-usage.spec.ts --exclude '**/.worktrees/**'
PLOY_AI_DELEGATION_DB_TEST=1 pnpm exec vitest run packages/db/test/ai-delegation.spec.ts --exclude '**/.worktrees/**'

The opt-in PostgreSQL fixture connects only to the local seeded PostgreSQL at 127.0.0.1:5433, using a connection-local temporary table created from the generated migration. It tests actual reservation SQL, rate-window rollover, persisted revocation and scope mismatch rejection without resetting or mutating existing project data. HTTP fixtures exercise both public and internal route trees with mocked upstream inference; they do not bill real models.

How is this guide?

Last updated on