AI credentials & proxy
Give your sandboxes AI keys from a credential profile: a named set of provider keys and custom APIs, with several accounts per provider. Turn on the AI proxy and a sandbox gets placeholder keys instead of the real ones: the real keys stay encrypted on froog, which forwards the agents' requests. The proxy is off by default. Nothing changes until you turn it on.
$ echo "$ANTHROPIC_API_KEY" | froog ai key-add anthropic # into the default profile
$ echo "$ANTHROPIC_KEY_2" | froog ai key-add anthropic # a second account, used when the first runs out
$ froog ai proxy on # sandboxes started from now on get placeholders
$ froog ai usage # requests, tokens and estimated cost per sandbox
$ froog ai block my-box # refuse its AI requests at once
Credential profiles
Every account has a default profile, named default. It is made the first time you list or add to it. A sandbox gets the profile its environment names (froog ai env ci ci-bots, API PUT /v1/ai/environments/{env} {"profile"}, null means the default), or the default. froog ai use my-box ci-bots (API PUT /v1/sandboxes/{name}/ai {"profile"}, "" goes back to the environment's) gives one sandbox another one: proxied requests use it at once, the variables change at the next start. The API never returns a key: a profile lists each account's provider, its last four characters (…7f3a) and its id.
$ froog ai profile-new ci-bots --copy-from default
$ echo "$OPENAI_API_KEY" | froog ai key-add openai --profile ci-bots
$ echo "$STRIPE_KEY" | froog ai key-add STRIPE_KEY --base-url https://api.stripe.com --profile ci-bots
$ echo "$ACME_KEY" | froog ai key-add ACME_KEY --base-url https://api.acme.dev --secret-header X-Api-Key
$ froog ai profiles
$ froog ai key-rm cred_0123456789abcdef --profile ci-bots
$ froog ai profile-set ci-bots --default
| Provider | The sandbox gets | Proxied to |
|---|---|---|
anthropic | ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL | api.anthropic.com (Messages, streaming included) |
openai | OPENAI_API_KEY, OPENAI_BASE_URL (…/openai/v1) | api.openai.com (Chat Completions, Responses, embeddings, models) |
gemini | GEMINI_API_KEY, GOOGLE_GEMINI_BASE_URL | generativelanguage.googleapis.com |
deepseek, mistral | DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL. MISTRAL_API_KEY, MISTRAL_BASE_URL | api.deepseek.com, api.mistral.ai |
openrouter, xai | OPENROUTER_API_KEY, OPENROUTER_BASE_URL. XAI_API_KEY, XAI_BASE_URL (both …/v1) | openrouter.ai/api, api.x.ai |
bedrock | AWS_BEARER_TOKEN_BEDROCK, ANTHROPIC_BEDROCK_BASE_URL, AWS_REGION, and CLAUDE_CODE_USE_BEDROCK=1 when there's no Anthropic key | bedrock-runtime.REGION.amazonaws.com, signed with SigV4 |
| a custom API | NAME, NAME_BASE_URL | its base URL |
A key is one token of up to 8192 printable characters, without spaces or line breaks (400 otherwise). The CLI reads it from stdin when you leave it out, so it stays out of your shell history. Bedrock takes a Bedrock API key, or AWS keys as ACCESS_KEY_ID:SECRET_ACCESS_KEY[:SESSION_TOKEN], and --region (default us-east-1). A custom API is any HTTPS API, OpenAI-compatible ones included: its name is A-Z, 0-9 and _, up to 64 characters. Provider names (ANTHROPIC_*, OPENAI_*, AWS_*, FROOG_*, …) and names ending in _BASE_URL are reserved (400 reserved_name). The base URL must be https:// on the default port with a public DNS name. You also say where its secret goes, and the proxy puts it only there: a header, Authorization with Bearer unless you say otherwise (--secret-header X-Api-Key, --secret-prefix "Token ", API secret_header, secret_prefix), or a query parameter (--secret-query api_key, API secret_query). Headers the transport or the browser owns (Host, Cookie, Content-Type, Origin, …) can't hold it (400). The dashboard takes it as Authorization: Bearer, X-Api-Key or ?api_key. Up to 20 profiles and 50 keys per profile. A profile can't be deleted while it's the default (409 is_default) or an environment names it (409 in_use). Without the proxy, a sandbox gets the real key of the account in use, and a custom API's variables hold its secret and its own base URL.
A provider key you set with froog env (ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, …) still works: it counts as the last account of its provider in every profile, so turning the proxy on keeps it out of sandboxes too. One whose base URL variable you also set yourself (ANTHROPIC_BASE_URL) passes unchanged: add that API as a custom API to proxy it. A sandbox's own variables (-e OPENAI_API_KEY=…) always win and are never proxied, and a sandbox started with --no-environment gets no AI credentials at all.
Several accounts per provider
Add a second key for a provider and it becomes its second account. The proxy uses the first account that isn't resting. When an answer says the account hit a limit, the proxy rests that account and sends the same request to the next one, at once: a 429 rests it for its Retry-After (default 60 seconds), and a 402 or a quota error code in the provider's JSON error (insufficient_quota, billing_hard_limit_reached, billing_not_active, credit_balance_too_low, …) for an hour. The words of an error message never count (a prompt can put them there): an Anthropic "credit balance is too low" error rests nothing, so remove or switch that account yourself. When every account is resting, the one back soonest is tried. froog ai profiles shows which account is in use and which rest, and why. froog ai switch cred_… (API PUT /v1/ai/profiles/{profile}/credentials/{id}/active) puts an account first and ends its rest. Switching works only through the proxy: without it, a sandbox gets the first account's key at its start.
The proxy
sandbox froog provider
ANTHROPIC_API_KEY=froog-ph-…
ANTHROPIC_BASE_URL=https://ai.froogcorp.com/anthropic
agent ── request + placeholder ──▶ placeholder of a running sandbox?
blocked? swap in the real key ── request ──▶ api.anthropic.com
◀────── answer, streamed ──── count the tokens ◀──────────── answer ──────
froog ai proxy on (API PUT /v1/ai/proxy {"enabled": true}, an account.admin key) applies to sandboxes at their next start. Stop and start a running one to switch it now. froog ai proxy shows the state and the proxy's URL.
- The placeholder is a secret of its own for each sandbox, worth nothing outside froog. It stops working when the sandbox stops or is deleted, and each start (a warm resume too, which rewrites
~/.froog/env) gives new ones: a process that kept the old one gets401 ai_placeholder_invalidand must be restarted. - The proxy sends each key only to its own provider, and only in that provider's key header (
x-api-key,Authorization,x-goog-api-key, SigV4 for AWS keys). A custom API gets its secret only in the header or query parameter its credential names. The placeholder anywhere else in the request (another header, the query string) is removed, never swapped, and a request with it in the path is refused (400 ai_placeholder_in_path). An answer header that holds a secret is dropped. Still, pick a base URL you trust: it sees the secret. - A built-in provider takes only its model endpoints. Anything else is
403 ai_path_not_allowed. Anthropic:/v1/messages,/v1/messages/count_tokens,/v1/models. OpenAI:/v1/chat/completions,/v1/responses(and below),/v1/embeddings,/v1/models. Gemini (v1beta,v1,v1alpha):models/MODEL:generateContent,:streamGenerateContent,:countTokens,:embedContent,:batchEmbedContents, and/models. Bedrock:/model/MODEL/invoke,invoke-with-response-stream,converse,converse-stream. DeepSeek, Mistral, OpenRouter and xAI:chat/completions,completions,embeddingsandmodels, with or without/v1. A custom API takes any path. No path may hold.or..segments. - Answers stream back as they arrive (server-sent events included). Nothing is buffered. Requests are limited to 16 MiB (
413 request_too_large), and the whole body must arrive within 60 seconds (408 request_timeout). At most 16 requests at once per sandbox and 64 per account, answers included until they end (429 ai_too_many_requests,Retry-After: 1). The API's per-address limit applies, and the same number per account (429 ip_rate_limited,account_rate_limited). No redirects are followed. A custom API must resolve to a public address. - Answers are the provider's, but nothing in them runs in a browser: only the headers an SDK reads come back (
Content-Type,Content-Length,Content-Encoding,Retry-After, request ids, rate limit headers, and the providers' ownanthropic-*,openai-*,x-ratelimit-*,x-amzn-*,x-goog-*), every answer carriesContent-Security-Policy: sandbox; default-src 'none'andX-Content-Type-Options: nosniff, and a type other than JSON, server-sent events, NDJSON, plain text or bytes becomesapplication/octet-stream. The proxy answers only at its own address (ai.froogcorp.com, neverapi.froogcorp.com/ai), and refuses a browser page or navigation, a froog session cookie and the dashboard's origin (403 ai_browser_refused). - froog never logs prompts, answers or keys: only the provider, the status and the sandbox.
- Subscription sign-ins are not proxied: the Claude token (
CLAUDE_CODE_OAUTH_TOKEN) and the ChatGPT login (~/.codex/auth.json) pass as they do without the proxy, because the agents renew and present them themselves.froog ai proxy on --withhold-subscriptions truekeeps them out of proxied sandboxes. The agents then use the proxied keys.froog promptand the agents work the same either way.
Proxy errors are JSON {"error": {"code", "message"}}: 400 ai_placeholder_in_path, 401 ai_placeholder_missing or ai_placeholder_invalid, 403 ai_blocked, ai_path_not_allowed or ai_browser_refused, 404 ai_no_credential (the profile has no key for that provider) or ai_unknown_provider, 408 request_timeout, 413 request_too_large, 429 ai_too_many_requests, ip_rate_limited or account_rate_limited, 502 ai_upstream_unreachable. Everything else is the provider's own answer.
Usage
For each proxied sandbox, froog counts requests and errors and the tokens per UTC day, provider and model, as the provider reports them in its answers (streamed ones included): input not read from a cache, output, cache reads and cache writes. cost_usd is an estimate at list prices built into froog, not a froog charge and not your provider's bill. Unknown models count tokens at $0. A sandbox that holds real keys has no usage here. Usage is kept 400 days.
$ froog ai usage --days 7 # every sandbox, most expensive first (GET /v1/ai/usage?days=7)
$ froog ai usage my-box # per day and model (GET /v1/sandboxes/my-box/ai)
Block a sandbox
froog ai block my-box (API PUT /v1/sandboxes/{name}/ai {"blocked": true}) refuses the sandbox's AI requests at once with 403 ai_blocked, running or stopped. froog ai unblock my-box accepts them again. Anyone who may change the sandbox may block it (sandbox.update, the in-sandbox key and organization members included). Unblocking it, or giving it another profile, needs a key with environment.write or account.admin that isn't limited to some sandboxes (so never the in-sandbox key: a blocked sandbox can't unblock itself, 403 api_key_limited or api_key_action_forbidden), and an owner or admin in an organization (403 org_role_forbidden). --all (API PUT /v1/ai/proxy {"blocked": true}) blocks every proxied sandbox of the account. A block has no effect on a sandbox that holds real keys. froog has no automatic spend cap: read GET /v1/sandboxes/{name}/ai on a schedule and block a sandbox past your limit.
API: GET/PUT /v1/ai/proxy, GET/POST /v1/ai/profiles, GET/PATCH/DELETE /v1/ai/profiles/{profile}, POST /v1/ai/profiles/{profile}/credentials {provider, value, name?, base_url?, region?, secret_header?, secret_prefix?, secret_query?}, DELETE …/credentials/{id}, PUT …/credentials/{id}/active, PUT /v1/ai/environments/{env}, GET /v1/ai/usage, GET/PUT /v1/sandboxes/{name}/ai. Profiles need environment.read or environment.write, the proxy switch account.admin, account usage account.read, a sandbox's AI sandbox.read or sandbox.update (unblocking and another profile: see above). Changes are in the audit log (ai.proxy, ai.credential_add, …), never with a key. The SDKs have them as getAiProxy, addAiCredential, getAiUsage, updateSandboxAi and so on.