# App AI Gateway Source: https://docs.appaigateway.com/ Give your apps access to AI providers without shipping provider keys, and see every request in one place. App AI Gateway sits between your apps and the AI providers they use. Your app calls the gateway, and the gateway calls OpenAI, Anthropic, Gemini, xAI or whichever provider you added. Your provider keys stay on the gateway, every request is checked before it goes out, and provider attempts are recorded so you can see where the money went. ![Your iOS app calls App AI Gateway, which holds the provider keys, applies per-user limits, records usage and cost, and forwards the request to the provider.](https://docs.appaigateway.com/overview.png) It is built for iOS apps first: the app talks to the gateway directly, and the gateway checks that the request comes from a real install of your app, from a signed-in user, and, where you require it, from a user who has paid. Server applications are supported too, with a private API key, so all your apps can be watched from one console. ## What you get - **Provider keys stay off devices.** Keys are encrypted the moment you add them and are never shown again. Your app never holds one. - **One gateway for all your apps.** Add an app in the console instead of building a backend for each one. - **Requests are checked before they reach a provider.** Apple App Attest, sign-in tokens from Firebase, Supabase, Auth0, Clerk or your own issuer, and a paid-entitlement check. - **Limits you set.** Requests per minute and per day and a monthly budget, per user and per app. - **Provider-native requests.** The gateway forwards the provider's own request and response formats, streaming included. Standard SDKs work by changing the base URL. - **Usage in one place.** Spend, tokens, errors and latency by app, model, provider, user, endpoint and app version. Block a user who is running up your bill. ## Get started Sign up at [console.appaigateway.com](https://console.appaigateway.com), bring a provider key, and follow the [Quickstart](https://docs.appaigateway.com/quickstart). Your apps call `https://api.appaigateway.com`; there is nothing to deploy. The guides describe the same apps, providers and APIs wherever your gateway runs. Examples use the hosted addresses by default. > **Self-hosting** > > Use your own console and API addresses in these examples. If both share one > address, use it for both. The [self-hosting guides](https://docs.appaigateway.com/self-hosting/deploy-to-cloudflare) > cover deployment, configuration and updates on your Cloudflare account. ## Where to start - [Quickstart](https://docs.appaigateway.com/quickstart): Add a provider key, create an app, and send a request in a few minutes. - [Integrate your app](https://docs.appaigateway.com/integrate/ios): Call the gateway from iOS with the Swift package, or from a server with an API key. - [Applications](https://docs.appaigateway.com/applications/create): Everything an app's policy can say: identity, providers, limits, endpoints. - [Automation and agents](https://docs.appaigateway.com/automation/agents): Let a script, CI job or AI agent manage your apps through the API. --- # Quickstart Source: https://docs.appaigateway.com/quickstart/ Add a provider key, create an app, and send your first AI request in a few minutes, from a server with curl or from an iOS app. This page takes you from an empty console to a working request. It uses a server application, because that is the shortest path: one key, one curl command. The last section shows the same thing for an iOS app. Sign up at [console.appaigateway.com](https://console.appaigateway.com). The sidebar shows the same three steps as this guide until your first request goes through. > **Self-hosting** > > Open your own console and replace `https://api.appaigateway.com` in the > examples with your gateway's API address. If you have not deployed yet, > follow [Self-hosting](https://docs.appaigateway.com/self-hosting/deploy-to-cloudflare) first. Prefer a terminal or coding agent? Follow the [CLI guide](https://docs.appaigateway.com/automation/cli) or [Use with AI agents](https://docs.appaigateway.com/automation/agents). Sign in to your existing account before creating resources so they appear in the same console. ## 1. Add a provider key Open **Providers** and click **Add provider**. - **Name** is how the provider is listed, for example `Prod OpenAI`. - **Provider** is the service the key belongs to. This guide uses OpenAI. - **Authentication** stays on **API key**. Paste the key you got from the provider. Click **Test provider** if you want the gateway to confirm the key works, then **Add provider**. The key is encrypted on arrival and never shown again. The list shows only its last characters so you can tell keys apart. The first provider of each type gets a short name, its **slug**, that your app uses in URLs. The first OpenAI key is `openai`, the first Anthropic key is `anthropic`, and so on. See [Add a provider key](https://docs.appaigateway.com/providers/add-a-provider) for several keys of one type. ## 2. Create an app Open **Apps** and click **New app**. 1. **Application name**: anything you like. The gateway builds the app's ID from it, for example `example-app-a1b2c3`. 2. **Application type**: choose **Server**. 3. **User authentication**: choose **No user identity** for now. You can turn on per-user identity later on the app's **Auth policy** page. 4. Click **Create app**. A dialog titled **Your application is ready** shows the app's API key. It is shown once, so copy it now and keep it somewhere safe. The dialog also shows the **Base URL** your app will call: ```text https://api.appaigateway.com/v1/apps/example-app-a1b2c3/proxy/{provider}/{provider_path} ``` `{provider}` is the provider slug from step 1, and `{provider_path}` is the provider's own API path. ## 3. Send a request Put the key in an environment variable and call OpenAI's Responses API through the gateway. Replace the app ID with yours. ```sh export APP_AI_GATEWAY_KEY="agw_…" curl --fail-with-body https://api.appaigateway.com/v1/apps/example-app-a1b2c3/proxy/openai/v1/responses \ -H "Authorization: Bearer $APP_AI_GATEWAY_KEY" \ -H 'Content-Type: application/json' \ -d '{"model":"gpt-5.6-sol","input":"Say hello."}' ``` The response is OpenAI's own, unchanged. Everything after `/proxy/openai/` is the path OpenAI documents, and the body is the body OpenAI documents. The gateway added your provider key on the way out and recorded the request on the way back. If you use an SDK instead of curl, point it at the gateway and keep everything else: ```python import os from openai import OpenAI client = OpenAI( api_key=os.environ["APP_AI_GATEWAY_KEY"], base_url="https://api.appaigateway.com/v1/apps/example-app-a1b2c3/proxy/openai/v1", ) ``` ## 4. See it in the console Open the app and go to **Usage**. The request is in **Recent events** with its model, tokens, cost and latency, and the chart above it shows the day's spend. The **Apps** page now shows month-to-date spend and requests across all your apps. ## If something failed | Response | What it means | | --- | --- | | `502 provider_not_configured` | No provider has the slug in the URL. Check the Providers page. | | `400 pricing_not_configured` | The model is not in the price catalog. Add a price under the provider's **Pricing** action, or use a catalogued model. | | `401 auth_required` | The key is missing or wrong, or the app was created as an iOS app. | | `403 path_not_allowed` or `model_not_allowed` | The app's **Provider access** restricts paths or models. New apps allow inference endpoints and priced models. | The full list is in [Errors and limits](https://docs.appaigateway.com/integrate/errors). ## The same for an iOS app An iOS app has no key to ship. Instead the app proves it is a real install of your app with Apple App Attest, and the gateway gives it a short-lived token. 1. **New app**, type **iOS application**. Enter your **Apple Team ID** and **Bundle ID** from your Apple Developer account and Xcode. 2. **User authentication**: choose **Unauthenticated users** to start. Each install then counts as one user, with the default limits of 100 requests a minute and 10,000 a day. 3. Add the AppAIGateway Swift package to your project and send a request: ```swift import AppAIGateway let gateway = AppAIGatewayClient( appID: "example-app-a1b2c3", baseURL: URL(string: "https://api.appaigateway.com")!, authMode: .appAttestInstall ) let (data, _) = try await gateway.withAuthorizedRequest( provider: .openai, providerPath: "v1/responses", body: Data(#"{"model":"gpt-5.6-sol","input":"Say hello."}"#.utf8), contentType: "application/json" ) { try await URLSession.shared.data(for: $0) } print(String(decoding: data, as: UTF8.self)) ``` App Attest needs a real device. It is not available in the Simulator. The **Apps** page generates this snippet for your own app once it exists, and [iOS with the Swift package](https://docs.appaigateway.com/integrate/ios) covers sign-in, token refresh and error handling. To see a complete app first, [run the demo app on your iPhone](https://docs.appaigateway.com/demo-ios-app): it chats, generates images and transcribes voice notes through a gateway app of your own. ## Next steps - [Limits](https://docs.appaigateway.com/applications/limits) to cap what a user and an app may spend. - [User authentication](https://docs.appaigateway.com/applications/auth-policy/user-authentication) to tie requests to signed-in users from Firebase, Supabase, Auth0, Clerk or your own issuer. - [Custom endpoints](https://docs.appaigateway.com/applications/custom-endpoints) to keep the model and parameters on the gateway, so you can change them without an app release. - [Automation and agents](https://docs.appaigateway.com/automation/agents) to hand the setup to a script or an AI agent. --- # Run the demo app on your iPhone Source: https://docs.appaigateway.com/demo-ios-app/ Build the open-source demo app and run it on your iPhone through your own gateway app, with chat, images and voice notes from one OpenAI key and no sign-up. The demo app is a small SwiftUI app that chats, generates images and turns voice notes into text through App AI Gateway, from a real iPhone with App Attest, the way your own app would. Its source is in [`examples/ios`](https://github.com/maxceem/app-ai-gateway/tree/main/examples/ios). This guide takes about ten minutes: a gateway app from the command line, then the demo built onto your phone. You do not need to sign up first. The CLI starts a temporary account, and you can claim it at the end to keep it. The steps work the same for a person at a terminal and for an AI agent. Where they differ, both ways are given. Agents should also read [Notes for AI agents](#notes-for-ai-agents). ## What you need - A Mac with Xcode 26, signed in to your Apple ID under **Xcode → Settings → Accounts**. Xcode creates the signing profile App Attest needs, and it cannot without the account. - An iPhone on iOS 18 or later with **Developer Mode** on, connected to the Mac by cable or on the same network. - Your Apple **Team ID**, ten characters such as `ABCDE12345`. It is on your Apple Developer **Membership** page, and in your development certificate: `security find-certificate -c "Apple Development" -p | openssl x509 -noout -subject` prints it as `OU=`. - An OpenAI API key. OpenAI serves all three things the demo does, so a single key lets you try chat, images and transcription. - Node.js 22.19 or later, for the CLI. ## 1. Install the CLI ```sh npm install -g @maxceem/agw agw deployment status ``` If you already have an account, run `agw account login` now so the app lands in it. Otherwise the next step starts a new temporary one. ## 2. Add your OpenAI key At a terminal, enter the key in a hidden prompt: ```sh agw provider add --type openai --name "OpenAI" --key-prompt ``` An agent hands this step to you instead, so the key never passes through it: ```sh agw provider add --type openai --name "OpenAI" --browser --no-open --json ``` Open the URL it returns, check the provider shown, and paste the key there. The agent then finishes with `agw operation wait --json`. `agw provider list` shows the provider with the slug `openai`. ## 3. Create the app for the demo Choose a bundle ID that is yours, such as `com.yourname.GatewayDemo`, and create an iOS app with it and your Team ID: ```sh agw app add --type ios --name "Gateway Demo" \ --team-id ABCDE12345 \ --bundle-id com.yourname.GatewayDemo \ --attest-environments development ``` The output names the app's ID, such as `gateway-demo-a1b2c3d4e5f6`; with `--json` it is `result.app.id`. The app accepts App Attest from development builds, which is what Xcode installs, and can reach every provider you add. ## 4. Get the code ```sh git clone https://github.com/maxceem/app-ai-gateway.git cd app-ai-gateway/examples/ios ``` ## 5. Build and run it on your iPhone **In Xcode.** Open `GatewayDemo.xcodeproj`. Under the target's **Signing & Capabilities**, choose your team and set **Bundle Identifier** to the one from step 3. Pick your iPhone and press **Run**. In the app, open the sidebar, then **Settings**: enter the app ID, and turn off **Simulate responses**. **From a terminal.** Find the iPhone's ID, the long value in parentheses: ```sh xcrun xctrace list devices ``` Build, install and launch, with your Team ID, bundle ID, app ID and iPhone ID: ```sh xcodebuild -project GatewayDemo.xcodeproj -scheme GatewayDemo \ -configuration Debug -destination 'id=IPHONE_ID' -derivedDataPath build \ DEVELOPMENT_TEAM=ABCDE12345 PRODUCT_BUNDLE_IDENTIFIER=com.yourname.GatewayDemo \ -allowProvisioningUpdates build xcrun devicectl device install app --device IPHONE_ID \ build/Build/Products/Debug-iphoneos/GatewayDemo.app xcrun devicectl device process launch --device IPHONE_ID --terminate-existing \ com.yourname.GatewayDemo -- \ -GatewayURL https://api.appaigateway.com -AppID APP_ID -Simulated NO ``` The arguments after `--` fill in the app's settings, as if you had typed them in **Settings**. The app keeps them, so later launches from the home screen need nothing. The phone must be unlocked for the launch. ## 6. Try it Each feature starts on an OpenAI model: - **New chat** streams an answer from `gpt-6-sol`. - **New image** draws with `gpt-image-2`. - **New transcription** records a voice note and sends it to `gpt-transcribe`. The model menu at the top switches models. The first request takes a moment longer, while the phone registers its App Attest key with the gateway. See what each request did with `agw usage show --app APP_ID`, or on the app's **Usage** page in the console. To try another provider, add its key as in step 2, then turn it on in the app's **Settings → Providers**. ## If something fails | What you see | What to do | | --- | --- | | `No Accounts`, or a profile that "doesn't include the App Attest capability" | Sign in to Xcode under **Settings → Accounts**, then build again. | | The launch says the device is locked | Unlock the iPhone and run the launch command again. | | `attest_failed` in the app | The Team ID or bundle ID of the build differs from step 3. Build with the same values. | | `provider_not_configured` | The provider is turned on in the app but not added in step 2. Add it, or turn it off in **Settings → Providers**. | | `model_not_allowed` or `path_not_allowed` | The app's provider access was narrowed. `agw app show APP_ID` shows what it allows. | The full list of codes is in [Errors and limits](https://docs.appaigateway.com/integrate/errors). ## Keep the account A temporary account expires unless you claim it. `agw account status` shows when. To keep it, run `agw account claim`, open the URL in your browser, and create your sign-in there. The demo app keeps working throughout. ## Notes for AI agents - Before you start, ask the person for their Team ID (or read it with the certificate command above), a bundle ID they own, and which iPhone to use. - Never ask for a provider key in the conversation or put it in a command. Use the browser handoff in step 2 and wait for the person to finish it. - Some steps are the person's alone: signing in to Xcode, turning on Developer Mode, unlocking the phone, and claiming the account. Ask for each when you reach it. - Run `agw account status --json` before step 2. If an account is already connected and the person wanted a new one, stop and ask. --- # Apps and providers Source: https://docs.appaigateway.com/how-it-works/apps-and-providers/ The two things you configure, what each one owns, and how a request moves between them. You configure two kinds of things in the console. **Providers** are the AI services you hold keys for. **Apps** are the applications that call them. A request always names one app and one provider: ```text https://api.appaigateway.com/v1/apps/{app}/proxy/{provider}/{provider_path} ``` ## Providers are shared, apps are separate A provider key is added once and is available to every app you have. You do not add the same OpenAI key to each app. An app decides which of your providers it may use on its **Provider access** page, and new apps may use all of them until you say otherwise. Each provider has a **slug**, the short name in the URL above. The first provider of each type takes the type name: `openai`, `anthropic`, `gemini`, `xai`. A second key of the same type needs a slug of its own, such as `openai-dev`, so an app can be pointed at one key or the other. See [Add a provider key](https://docs.appaigateway.com/providers/add-a-provider). An app owns everything about how it may call those providers: - **Auth policy**: how the app proves it is your app, whether requests belong to signed-in users, and whether those users must have paid. - **Provider access**: which providers, paths and models the app may use. - **Model rewrites**: model names the gateway maps to others on the way through. - **Custom endpoints**: endpoints of your own whose model and parameters live on the gateway. - **Limits**: requests per minute and per day, and a monthly budget, per user and per app. ## The app ID is permanent The gateway builds an app's ID from its name plus a short random suffix, for example `example-app-a1b2c3`. The ID is the `{app}` segment of every URL the app calls, so your app compiles it into its base URL. It cannot be chosen and it cannot be changed. Renaming the app changes only the name. The ID is not a secret and it is not a credential. Knowing it lets nobody call the app; App Attest or an API key still has to pass. ## What happens to a request 1. The gateway reads the app from the URL and checks the credential: a gateway token for an iOS app, or an API key for a server app. 2. It works out which user the request belongs to, if the app identifies users. 3. It checks the app's proxy policy: is this provider allowed, this path, this model? Can its cost be accounted for? 4. It refuses a blocked authenticated user, then checks the app's limits, per user first and then per app. 5. It adds your provider key and forwards the request to the provider. The body goes through unchanged, apart from a few same-format adjustments such as a model rewrite or an output cap you configured. 6. The provider's response streams back to your app as the provider sent it. The gateway reads the usage out of the stream and records the request with its tokens, cost, latency and outcome. Requests refused before forwarding never reach a provider and incur no provider charge. **Usage** records provider attempts, including failed attempts in a fallback chain. **Auth & Errors** shows authentication events and sampled requests stopped by limits or a user block; those samples are not usage totals. ## Provider-native, not unified The gateway does not define its own request format. You send OpenAI's body to an OpenAI provider and Anthropic's body to an Anthropic provider, and you get each provider's own response back. The provider's documentation stays the reference for every field. This is what lets the official SDKs work as they are. Point the OpenAI SDK's base URL at `…/proxy/openai/v1` and it appends `responses` or `chat/completions` itself. See [Calling the provider proxy](https://docs.appaigateway.com/integrate/proxy). ## Changes take up to a minute The gateway caches app and provider configuration for sixty seconds. A saved change to an app's policy, a rotated key, a disabled provider: all of these are live everywhere within a minute. Blocking a user is faster: new token exchanges are refused at once, and an already issued token stops working within ten seconds. --- # Identity and entitlement Source: https://docs.appaigateway.com/how-it-works/identity/ The three questions the gateway asks about a request, and which combinations an app can be configured for. Every request answers up to three questions, and each app's **Auth policy** page is organised around them. | Question | Answered by | Set under | | --- | --- | --- | | Is this my app? | Apple App Attest, or an application API key | **Application identity** | | Which user is it acting for? | A sign-in token, the app install itself, or a header from your backend | **User authentication** | | Has that user paid? | A claim in the sign-in token | **Subscription check** | The first question always has an answer. The second and third are optional, and the third needs the second. ## Application identity An **iOS application** proves itself with Apple App Attest. The device holds a key in the Secure Enclave, Apple attests that the key belongs to a genuine install of your app, and the gateway checks that attestation against your Team ID and Bundle ID. The app then exchanges an assertion for a gateway token that lasts one hour and, by default, signs every request it sends with that same key. There is no shared application API key to embed in the app. A **server** application proves itself with an API key, a long-lived `agw_…` secret. Without signed-in users it sends that key on every request; with signed-in users it exchanges the key and the user's JWT for a gateway token. The key belongs in a secret manager, never in a client. The AppAIGateway Swift package does the App Attest exchange for you. See [iOS with the Swift package](https://docs.appaigateway.com/integrate/ios). ## User authentication Identifying users is what makes per-user limits, per-user usage and blocking possible. An app can identify them in one of these ways. | Choice in the console | App type | Who the user is | | --- | --- | --- | | **Signed-in users only** | iOS and server | The subject of a sign-in token from your identity provider, verified by the gateway | | **Unauthenticated users** | iOS | The app install. The App Attest key is the identity | | **Your backend sends the user id** | Server | Whatever your backend puts in a header you choose, taken on trust | | **No user identity** | Server | Nobody. Requests are not linked to a user and per-user limits do not apply | **Signed-in users only** is the strongest choice. Your app sends the ID token it already has from Firebase, Supabase, Auth0, Clerk, Sign in with Apple or your own issuer, and the gateway verifies the signature, the issuer and the audience against your configuration before it trusts the user ID inside. **Unauthenticated users** is the quick start for an iOS app with no sign-in. Each install counts as one user, so per-install limits and blocks work. The identity does not survive reinstalling the app, so a determined user can shed a block by starting over. **Your backend sends the user id** belongs on a server you control. The gateway takes the value as given, so it is never appropriate for a client an end user runs. Details and the settings for each identity provider are on [User authentication](https://docs.appaigateway.com/applications/auth-policy/user-authentication). ## Subscription check With signed-in users, the gateway can also require that the sign-in token carries a claim saying the user has paid. The gateway never talks to the App Store itself. Something else, typically RevenueCat's Firebase extension or your own backend, writes the entitlement into the user's token, and the gateway checks for it on every token exchange. Because the claim is written after the purchase, there is a short window where a paying user holds a token without it yet. The gateway reports that as a distinct outcome so your app waits instead of asking the user to sign in again. See [Subscription check](https://docs.appaigateway.com/applications/auth-policy/subscription-check). ## What the gateway token is An iOS app, or a server app with signed-in users, does not send its user's sign-in token on every request. It exchanges its proof and that token once at the app's token endpoint and receives a **gateway token**, valid for one hour, that stands for both. Requests carry that token and an `X-App-Version` header. The Swift package refreshes it before it expires. A server app with no signed-in users skips the exchange and sends its API key directly. ### When App Attest is verified Each iOS app has a **Verify App Attest on** setting, `verify_on` in its configuration, with two values. | Value | What the gateway checks | | --- | --- | | **Every request** (`every_request`, the default) | The assertion at each token exchange, and a fresh assertion on every request | | **Token exchange** (`token_exchange`) | The assertion at each token exchange only; requests carry the gateway token alone | Either way the gateway token names the install it was issued to, so request limits, budgets and usage are counted per install in both modes. Apps created before this setting was added were set to **Token exchange**, so installs already shipped keep working. A new app, or a configuration that leaves the field out, verifies every request. ### Every request With **Every request**, an iOS app's gateway token is bound to the App Attest key of the install it was issued to, and is not enough on its own. Every request sent with it also carries an `X-AppAttest-Proof` header: an App Attest assertion that the key signed over that one request. The proof covers: - your app's ID, the HTTP method, and the URL path and query exactly as sent; - a hash of the request body; - a hash of the gateway token itself; - the device's timestamp and a random value. The gateway checks the signature against the install's registered key, and the assertion's counter against every counter that install has already used, so each proof is accepted once. The timestamp is part of what is signed, but the gateway does not compare it with its own clock: a device whose clock is wrong is not refused, and the counter is what stops a replay. What someone who copies a gateway token off the network, with an HTTPS inspector for example, can do with it: nothing. Without the device's key they cannot sign a new request, a proof they copied alongside it covers only the request it came with, and that request has already been served. A request without a proof is refused with `401 assertion_required`. The Swift package 1.4 or later signs every request for you. See [Per-request proof](https://docs.appaigateway.com/integrate/ios#per-request-proof). ### Token exchange With **Token exchange**, the assertion is checked once per hourly token, and requests carry only the token. A token copied off the network works from anywhere until it expires, at most an hour. Choose it when the builds in use are on a Swift package older than 1.4; switch to **Every request** once they have been updated. ## Combinations | App type | User authentication | Subscription check | | --- | --- | --- | | iOS | Signed-in users | Available | | iOS | Unauthenticated users | Not available, there is no token to check | | Server | Signed-in users | Available | | Server | Backend sends the user id | Not available | | Server | No user identity | Not available | Two pairings do not exist. An iOS app cannot name its own user in a header, because the device is the party being limited. A server app cannot use the app-install identity, because it has no attested key. --- # Limits and blocking Source: https://docs.appaigateway.com/how-it-works/limits/ The limits you set on your app's users, how they are counted, and how to block a user. An app's **Limits** page sets what its callers and the app as a whole may do. There are two scopes, each with the same three fields. | Field | Per-install limits (iOS) | Per-user limits (server) | Per-app limits | | --- | --- | --- | --- | | **Requests per minute** | Each install separately | Each user separately | All callers of the app together | | **Requests per day** | Each install separately | Each user separately | All together | | **Monthly spending budget (USD)** | Each install, or each signed-in user if you choose | Each user separately | All together | An install is one copy of your app on one device. Reinstalling the app starts a new one, and one person on a phone and a tablet is two. Counting iOS request limits per install means each device a person uses has its own minute and its own day. The budget follows the same rule unless you measure it per user, which only an app with **Signed-in users only** can do, because only then is there a person behind several installs. Nothing switches it for you. Leave a field empty for no limit. A request refused by a per-install or per-user limit answers `429 app_rate_limited` or `429 app_budget_exhausted` with `data.scope` set to `install` or `user`: `install` for an iOS app's request limits and an install budget, `user` for a server app's limits and a budget measured per user. A request refused by an application limit answers the same codes with `data.scope` set to `app`. All carry a `Retry-After` header. Per-install and per-user limits are checked before application limits, so one heavy caller cannot drain the shared window before their own limit stops them. ## How they are counted - **Rate windows are fixed, not sliding.** A user can spend a full minute's allowance at the end of one minute and again at the start of the next, so a burst of up to twice the per-minute value is possible across a boundary. Days roll over at midnight UTC. - **Budgets settle after the response.** A request's cost is known only when its response completes, and the limit counter reads the month's spend at most every ten seconds, so a budget stops requests within about ten seconds of the one that crossed it. A single very expensive call, or a burst inside those seconds, can overshoot. - **Budgets use UTC calendar months.** Turning on an application budget part-way through a month counts what the month has already spent. Spend is kept per install and per user alike, so switching an iOS app's budget from one to the other mid-month counts the month so far too. - **A budget of zero is a closed door.** It stops all billable traffic for that scope. Leave the field empty for unlimited instead. A request limit of zero is rejected as a likely typo. - **Edits take up to a minute** to apply everywhere. Per-user limits need an app that identifies users. An app with **No user identity** has nobody to apply them to, and the console disables its per-user fields. An iOS app always identifies its installs. ## Blocking a user Blocking is not a limit. On the app's **Users** page, **Block** refuses every request from that user, on every install, with `403 auth_required`. New token exchanges are refused at once, and a token the user already holds stops working within ten seconds. **Unblock** restores them the same way. Users identified by a backend header must be blocked in your backend; the gateway does not offer blocking for them. ## What a user can see about themselves An app that identifies users can call `GET /v1/apps/{app}/me` with the same credentials as a proxy request to read the limits that apply, requests today, what is left of the daily limit, the month's spend, and whether the user is blocked. On an iOS app the request counts and, by default, the spend are the install's, and `install_id` names it. It is meant for showing a user where they stand before they hit a limit. See [Errors and limits from the client side](https://docs.appaigateway.com/integrate/errors). --- # Add a provider key Source: https://docs.appaigateway.com/providers/add-a-provider/ Store a provider key once in the console, test it, and manage it over time. You bring your own provider keys. You add each one on the **Providers** page, the gateway encrypts it on arrival, and nothing ever shows it again. The list displays only the last four characters, so you can tell two keys apart. A provider you add is shared by every app you have. ## Add a provider Open **Providers** and click **Add provider**. The dialog asks for the fields in the order you decide them. | Field | What to enter | | --- | --- | | **Name** | How this provider is named in the list, for example `Prod OpenAI`. | | **Provider** | The service the key belongs to. See [Supported providers](https://docs.appaigateway.com/providers/supported-providers). | | **Authentication** | **API key** to call the provider directly, or **Use gateway** to route through a Cloudflare or Vercel AI Gateway you connected under [Gateways](https://docs.appaigateway.com/providers/gateways). | | **API key** | The key from the provider. Shown only with **API key**. | | **Base URL (optional)** | Leave empty to use the provider's own API. Fill it to point this provider at an endpoint you control. See [Custom base URLs](https://docs.appaigateway.com/providers/custom-base-urls). | | **Gateway** | Which connected gateway carries the traffic. Shown only with **Use gateway**. The provider's own key lives in that gateway, so this row carries no secret. | | **Slug** | Appears only when the default slug is already taken. See [Slugs](#slugs). | Click **Test provider** if you want the gateway to check the key, then **Add provider**. Saving does not call the provider. The key is stored exactly as you paste it, so a provider outage or a key that is not live yet never stops you from saving. The API equivalent: ```json POST /v1/admin/providers { "type": "openai", "name": "Prod OpenAI", "secret": "sk-..." } ``` Add `"slug": "openai-dev"` for a custom slug, `"baseUrl"` for a custom endpoint, or `"providerGatewayId"` instead of `"secret"` to route through a gateway. Exactly one of `secret` and `providerGatewayId` must be present. The response carries `secretHint`, never the key. The full contract is in the [API reference](https://docs.appaigateway.com/api). ## Test provider **Test provider** runs the cheapest authenticated call the provider offers, such as its list-models endpoint, and nothing else. Nothing is stored. The answer is one of three verdicts: | Verdict | What it says | Meaning | | --- | --- | --- | | Works | "Works. The provider accepted this credential." | The provider answered the test call with this key. | | Failed | "OpenAI answered with HTTP 401, so this key could not be used." | The provider refused the key with HTTP 401 or 403. | | Unconfirmed | "There is no test call for Perplexity, so nothing was checked." or "OpenAI did not answer in time, so nothing is proven either way." | Nothing was proven, in either direction. Add the key if you know it is right. | Other non-success statuses, including 404, 429 and 5xx, are unconfirmed: they do not establish whether the key is valid. Not every provider has a test call. The per-provider list is in [Supported providers](https://docs.appaigateway.com/providers/supported-providers#what-test-provider-calls). When the provider is routed through a gateway, the test goes through that gateway. A refusal then reads "The gateway answered with HTTP 401. Check that it holds a stored key for OpenAI." The API equivalent is a dry run of the create call with the same `type` and `secret` (or `providerGatewayId`, or `baseUrl`): ```json POST /v1/admin/providers/test { "type": "openai", "secret": "sk-..." } ``` A working key answers `{ "validated": true }`. A refused key answers `400 provider_key_invalid`. An inconclusive test answers `{ "validated": false, "reason": "no_probe" | "unreachable" | "unexpected_status" }` with the upstream `status` where there was one. ## Slugs A slug is the URL segment your apps use to name this provider: ```text /v1/apps/{app}/proxy/{slug}/{provider-path} ``` It is `^[a-z0-9][a-z0-9-]{0,62}$` and defaults to the provider type. The first OpenAI key is reachable at `/proxy/openai/...` with nothing to configure, the first Anthropic key at `/proxy/anthropic/...`, and so on for every type in [Supported providers](https://docs.appaigateway.com/providers/supported-providers). A slug equal to a provider type name is reserved for that type, so `/proxy/anthropic/...` can never resolve to an OpenAI key. The provider type, and with it the upstream URL, the auth header and the price catalog, comes from the provider the slug resolves to, never from the URL itself. ### Several keys of one type You can add as many providers of one type as you like: a second OpenAI key for development, one routed through a gateway, one pointed at your own endpoint. Only the first gets the default slug. When you pick a type whose default slug is already in use, the dialog shows a **Slug** field with the hint "Another instance already uses `openai`, so this one needs its own `/proxy//...` segment." Enter something like `openai-dev`. | Provider | Slug | Your app calls | | --- | --- | --- | | OpenAI, first key | `openai` | `POST /v1/apps/my-app/proxy/openai/v1/responses` | | OpenAI, second key for dev | `openai-dev` | `POST /v1/apps/my-app/proxy/openai-dev/v1/responses` | | OpenAI through Cloudflare AI Gateway | `openai-cf` | `POST /v1/apps/my-app/proxy/openai-cf/v1/chat/completions` | | Anthropic, only key | `anthropic` | `POST /v1/apps/my-app/proxy/anthropic/v1/messages` | Through the API, creating a second provider without a `slug` answers `409 slug_taken`. A provider holds its slug for as long as it exists, disabled or not. Only deleting it frees the slug. An app's [provider access](https://docs.appaigateway.com/applications/provider-access) and its [custom endpoints](https://docs.appaigateway.com/applications/custom-endpoints) name providers by slug. That is how one app can be limited to `openai-cf` while another uses `openai`. ## The Providers list Each row shows the **Name**, the **Provider** type, the **Slug**, and under **Auth** how it authenticates: `API key: ...ab12` for a direct key (its last four characters), plus `Base URL` when one is set, or `Gateway: ` for a routed provider. A paused provider carries a `disabled` badge. The row's actions menu offers **Pricing**, **Update key**, **Disable provider** (or **Enable provider**), and **Delete provider**. ## Update key **Update key** replaces the key in place. The dialog says it plainly: "The new credential replaces the old one in place. Custom pricing is kept, and requests pick it up within a minute." Paste the **New API key** and click **Update key**. The provider keeps its slug and its custom prices, so nothing in any app changes. Prefer this over delete-and-recreate for exactly that reason. The same dialog has a **Base URL** field. Changing the origin needs the key, because the stored key is never sent to a new origin. See [Custom base URLs](https://docs.appaigateway.com/providers/custom-base-urls#changing-the-origin). A gateway-routed provider has no key of its own, so **Update key** is greyed out there. Rotate the shared token once with **Update token** on the [Gateways](https://docs.appaigateway.com/providers/gateways#update-token) page and every provider behind that gateway follows. ```json PUT /v1/admin/providers/{id} { "secret": "sk-new...", "revision": 7 } ``` Use the `revision` returned by the provider list. If another edit wins first, the API returns `409 conflict`; read the provider again before retrying. On a gateway-routed provider this answers `409 provider_gateway_managed` and points you at the gateway's rotate endpoint. ## Disable or delete These are two different intentions. **Disable provider** is a reversible pause. The confirm dialog says "Requests to `openai` start failing within a minute" and lists the apps whose configuration names that slug. The key, the custom pricing and the slug all stay. Requests to the slug answer `502 provider_disabled`. **Enable provider** puts it straight back without reclaiming the slug, because nothing could have taken the slug in the meantime. **Delete provider** is permanent. The confirm dialog says "Apps using OpenAI start failing within a minute, and any custom pricing on this provider is deleted with it. This cannot be undone. Update the key instead if you only want to replace it, or disable the provider to pause it and keep the key." Deleting takes the encrypted key and the custom prices with it and frees the slug for reuse. Requests to the slug then answer `502 provider_not_configured`. An app's configuration may keep naming a disabled or deleted slug. Its proxy policy page shows the row with a `disabled` or `deleted` badge, and a named endpoint whose primary provider is disabled can use its fallbacks. Deleting that primary provider instead makes the endpoint fail with `provider_not_configured`; update the endpoint before deleting its provider. ```json PUT /v1/admin/providers/{id} { "status": "disabled", "revision": 7 } ``` ```text DELETE /v1/admin/providers/{id} ``` ## When an app names a provider you have not added An app can allow a provider before you have a key for it. The app's pages then show a banner titled **No credential for OpenAI**: "This app allows a provider you have not configured, so those requests fail with `provider_not_configured`." The link in the banner takes you to **Providers**. | Response | Meaning | | --- | --- | | `502 provider_not_configured` | No provider has this slug. Add one. | | `502 provider_disabled` | A provider with this slug exists but is paused. Enable it. | | `502 provider_unavailable` | The stored key could not be decrypted, or the gateway it routes through is gone. | The full list of proxy errors is in [Errors and limits](https://docs.appaigateway.com/integrate/errors). ## Propagation Every change on this page takes effect within a minute: a new key, a rotated key, a disable, a delete. The gateway caches resolved providers for up to sixty seconds, which is the same window as an app configuration change. ## How keys are protected - Every key is encrypted at rest before it is stored, and each one is bound to its own row, so a value lifted from one row cannot be replayed in another. - No response body from any endpoint contains a submitted key. The console clears its form the moment a create or update call settles. - Your apps never see a key either. The gateway adds it on the way out, and any provider key a client sends is stripped. See [Apps and providers](https://docs.appaigateway.com/how-it-works/apps-and-providers). --- # Supported providers Source: https://docs.appaigateway.com/providers/supported-providers/ The sixteen provider types, the native path each one expects, and which gateways can carry them. Every provider you add has a **type**. The type fixes the upstream API, the authentication header the gateway sends, and the price catalog the model is looked up in. The path after the slug is always the provider's own, verbatim, without a leading slash: ```text /v1/apps/{app}/proxy/{slug}/{provider-path} ``` The OpenAI-compatible providers disagree about their prefix more than about anything else, so the exact chat-completions path is listed for each. ## All provider types | Type | Service | Chat completions path | Through a gateway | Notes | | --- | --- | --- | --- | --- | | `openai` | OpenAI | `v1/chat/completions` | Cloudflare, Vercel | Also `v1/responses`, `v1/audio/transcriptions`, image generation. | | `anthropic` | Anthropic | `v1/messages` | Cloudflare, Vercel | Anthropic Messages, not chat completions. | | `xai` | xAI (Grok) | `v1/chat/completions` | Cloudflare, Vercel | Also `v1/responses`, `v1/stt`, image generation. Reports its own cost. | | `gemini` | Google AI Studio | `v1beta/openai/chat/completions` | Cloudflare, Vercel | Native API at `v1beta/models/{model}:generateContent`. | | `perplexity` | Perplexity | `chat/completions` | Cloudflare, Vercel | Sonar models. No test call. | | `deepseek` | DeepSeek | `chat/completions` | Vercel | No `v1/` in the base URL. | | `moonshot` | Moonshot AI (Kimi) | `v1/chat/completions` | Vercel | International `api.moonshot.ai` host. | | `groq` | Groq | `openai/v1/chat/completions` | Direct only | OpenAI API namespaced under `openai/`. | | `mistral` | Mistral | `v1/chat/completions` | Direct only | | | `together` | Together AI | `v1/chat/completions` | Direct only | Models are namespaced, for example `openai/gpt-oss-120b`. | | `fireworks` | Fireworks AI | `inference/v1/chat/completions` | Direct only | No catalog; price your models. No test call. | | `cerebras` | Cerebras | `v1/chat/completions` | Direct only | | | `huggingface` | Hugging Face Inference Providers | `v1/chat/completions` | Direct only | No catalog; pin the upstream in the model ID. No test call. | | `baseten` | Baseten Model APIs | `v1/chat/completions` | Direct only | | | `bytedance` | BytePlus ModelArk | `chat/completions` | Direct only | Version segment is already in the base URL. No test call. | | `openrouter` | OpenRouter | `v1/chat/completions` | Direct only | The only path it accepts. Reports its own cost. | The console shows these labels in the **Provider** picker: OpenAI, Anthropic, xAI, Gemini, Perplexity, DeepSeek, Groq, Mistral, Together AI, Fireworks AI, Cerebras, Moonshot AI, Hugging Face, Baseten, ByteDance Ark, OpenRouter. "Direct only" is the default, not a limitation of the type. A provider type is offered on a gateway once that pairing has been verified against the gateway's live catalog, because a provider slug or model namespace guessed from documentation is a guess about a URL and about which keys the gateway holds. Connect these types with their own API key. The paths in the table are for providers that call the provider's own API. A provider routed through a gateway uses the gateway's paths instead. See [Gateways](https://docs.appaigateway.com/providers/gateways#paths-on-each-route). ## Using an SDK For the OpenAI SDK, set the base URL to `https://api.appaigateway.com/v1/apps/YOUR-APP/proxy/openai/v1`. The SDK then appends standard paths such as `responses` or `chat/completions`. The same works for any OpenAI-compatible provider: point the base URL at that provider's slug and its prefix, for example `/proxy/groq/openai/v1`. Request bodies and responses keep the provider's native format. See [Calling the proxy](https://docs.appaigateway.com/integrate/proxy). ## Caveats by provider A few types carry caveats worth knowing before you add a key. **ByteDance** is the international BytePlus ModelArk endpoint in `ap-southeast-1`, and `/api/v3` is already part of the base URL, so the path is `chat/completions` with no version segment of your own. Keys and model catalogs are region-isolated, and models must be activated in your ModelArk console before they answer. The China Volcengine edition is a separate service and is not reachable here. For another region, set a [custom base URL](https://docs.appaigateway.com/providers/custom-base-urls). **Moonshot** is the international `api.moonshot.ai` host. A key issued on the China platform will not work. **Fireworks** names models per account (`accounts/{account}/models/{id}`), so no shipped catalog could be right for your account. Price the models you use under the provider's **Pricing** action. See [Model pricing](https://docs.appaigateway.com/providers/model-pricing). **Hugging Face** routes each request to whichever upstream it picks, and the same model costs up to ten times more on one upstream than another. Pin the upstream in the model ID (`author/model:provider`) and price that ID, or the recorded cost is a guess. **Gemini** has two APIs. The OpenAI-compatible one lives at `v1beta/openai/chat/completions`; the native one at `v1beta/models/{model}:generateContent`. Both work on a direct or Cloudflare-routed Gemini provider. Only the OpenAI-compatible one works through Vercel. **Groq** namespaces its OpenAI API under `openai/`, **DeepSeek** and **ByteDance** have no prefix at all, and **Fireworks** separates its inference plane from its control plane. Copy the path from the table rather than assuming `v1/`. **xAI** and **OpenRouter** report what each request cost, and the gateway records that figure rather than a locally computed one. Each has a section of its own below: [xAI](#xai) and [OpenRouter](#openrouter). ## What Test provider calls **Test provider** in the Add provider dialog, and `POST /v1/admin/providers/test` behind it, run the cheapest authenticated call the provider offers. A provider is missing from this table only when it has no call that can prove a key. That is deliberate: a test against a URL that answers 200 regardless of the credential would report every key as good. | Provider | Test call | | --- | --- | | OpenAI | `GET v1/models` | | xAI | `GET v1/models` | | Anthropic | `GET v1/models` | | Gemini | `GET v1beta/models` | | DeepSeek | `GET models` | | Groq | `GET openai/v1/models` | | Mistral | `GET v1/models` | | Together AI | `GET v1/models` | | Cerebras | `GET v1/models` | | Moonshot AI | `GET v1/models` | | Baseten | `GET v1/models` | | OpenRouter | `GET v1/key`, its key-status call. Its model list is public and would pass any key. | | Perplexity | None. Accepted unchecked. | | Fireworks AI | None. Its model list needs an account ID the key does not carry. | | Hugging Face | None. Its model list answers 200 to any token. | | ByteDance | None. ModelArk publishes no list-models call. | The verdicts are explained in [Add a provider key](https://docs.appaigateway.com/providers/add-a-provider#test-provider). ## Image generation Image endpoints are among an app's [default inference endpoints](https://docs.appaigateway.com/applications/provider-access#endpoints-this-provider-serves), so an app that lists no paths can call them. Whether an image model proxies depends on how its provider charges for it: | Provider | Image models | How the gateway bills them | | --- | --- | --- | | OpenAI | `gpt-image-*` on `v1/images/generations` and `v1/images/edits` | From the catalog, per token. Prompt text, input images and generated images each bill at their own rate. | | Gemini | Gemini image models (Nano Banana) on `v1beta/models/{model}:generateContent` | From the catalog, per token. Generated image tokens bill at the image rate. | | xAI | `grok-imagine-image` and its siblings on `v1/images/generations` and `v1/images/edits` | From the cost xAI reports. See [xAI](#xai). | | ByteDance | Seedream 5.0 flash, 5.0 lite, 4.5 and 4.0 on `images/generations` | From the catalog, per image: each model's price times the `usage.generated_images` the response reports. | | Together AI, ByteDance (Seedream 5.0 pro) and others | Billed by size, quality or steps, or at an estimated price | Not supported: refused with `pricing_not_configured`. | If you list an app's endpoints yourself, include the image paths, with `"clamp": "none"`. A list replaces the defaults. For OpenAI, for example: ```json "openai": { "allowed_paths": [ "v1/responses", { "path": "v1/images/generations", "clamp": "none" }, { "path": "v1/images/edits", "clamp": "none" } ], "allowed_models": ["gpt-image-2"] } ``` Call Gemini image models on the native `generateContent` path. Its responses say how many output tokens were images. The OpenAI-compatible endpoint does not, so there every output token, text included, bills at the image rate. Image generation inside another model's request is not covered: OpenAI's Responses API `image_generation` tool and Mistral's `image_generation` tool. In both, the usage counts only the calling model's tokens, so the recorded cost leaves out the generated images. To meter images, call the image endpoint directly. ## xAI **Cost is reported, not computed.** xAI's Chat Completions, Responses and image generation answers carry `usage.cost_in_usd_ticks`, what xAI actually billed for that request after any discount. The gateway records it as the event's cost with `cost_source: "reported"`, ahead of any catalog price. So on those APIs an xAI model proxies with **no local price**, which is what makes image models such as `grok-imagine-image` work at all: xAI bills images per image and reports no token counts for them. Streaming works the same way; the figure arrives in the final chunk. Two exceptions bill on local prices, like any other provider type: - **xAI's Anthropic Messages API and `v1/stt`.** xAI does not document a cost on them, so a model used there needs a price in the catalog or under the provider's **Pricing** action. - **The Vercel route.** Vercel answers through its own APIs rather than passing xAI's answer back, so the reported figure does not survive it. A Cloudflare AI Gateway route passes it through unchanged. If a reporting API ever answers without a cost, a local price for the model is used if there is one (`computed`), and otherwise the event is marked [unresolved](https://docs.appaigateway.com/providers/model-pricing#where-a-cost-comes-from). xAI's image endpoints are among an app's default inference endpoints. An app that lists its endpoints itself includes them with `"clamp": "none"`, because an image has no output tokens to cap. Video generation is not a default: xAI returns a job ID, and its result and cost arrive on a later poll the gateway cannot serve. ```json "xai": { "allowed_paths": [ "v1/chat/completions", "v1/responses", { "path": "v1/images/generations", "clamp": "none" } ], "allowed_models": [] } ``` ## OpenRouter OpenRouter is treated as a provider type, not as a gateway. It is the counterparty that charges you, and its catalog includes models with no direct provider at all. Two things follow. **Its slugs are the model IDs.** `google/gemini-3.6-flash` and `meta-llama/llama-4-maverick` are the model IDs here. They are what you send, what an app's allowed models and fixed models match, and what a usage event records. They are not translated: an OpenRouter provider and a direct Gemini provider name the same model differently, so moving an app between them means renaming the model. The leading namespace is also where the model's author comes from, so usage groups by **Google** or **Meta** the same way it does on a direct route. **Cost is reported, not computed.** Every OpenRouter chat-completions response carries `usage.cost`, what OpenRouter actually charged for that request. The gateway records it as the event's cost with `cost_source: "reported"`. So OpenRouter models proxy with **no local price**: the pricing rule is satisfied by the route reporting its own cost, which is why no OpenRouter section ships in the catalog. Streaming works the same way; the figure arrives in the final chunk. If a response ever comes back without a cost, the event is not recorded at zero. A custom price for that model is used if you have entered one (`computed`), and otherwise the event is marked [unresolved](https://docs.appaigateway.com/providers/model-pricing#where-a-cost-comes-from). **Bring your own key: two figures, both yours.** When a request runs on your own upstream provider key, OpenRouter's `usage.cost` is only its own fee (5% of what the request would have cost on OpenRouter), while the provider charges your key directly for the inference and OpenRouter reports that separately as `usage.cost_details.upstream_inference_cost`. The two are disjoint, so the gateway records their **sum**: both numbers left your accounts. > **Aborted streams are not reconciled** > > The cost arrives in the final chunk of a stream, so a client that disconnects > before it leaves the request unresolved at $0 in your usage, even though > OpenRouter charged for the tokens generated up to the disconnect. Watch the > unresolved count on OpenRouter providers, and set > [per-user limits](https://docs.appaigateway.com/applications/limits) on the apps that route through them. > Those bound how much can go unmeasured before you notice. OpenRouter providers accept **chat completions only**, at `v1/chat/completions`. OpenRouter does serve other APIs, but only its chat-completions response reports a cost, and a request the gateway could not account for is refused with `403 api_style_not_supported` rather than served for free. One request-shaping detail, invisible to your client's SDK: the gateway asks OpenRouter, with a header, to name the endpoint it routed to. Your request body is forwarded byte for byte. That answer is stored as `served_provider` (shown as **Served by** in the app's usage view), along with `served_model`, and `credential_source: "byok"` when the response says the inference was paid for with your own upstream key. All three are absent when OpenRouter does not say. See [Usage](https://docs.appaigateway.com/applications/usage). --- # Custom base URLs Source: https://docs.appaigateway.com/providers/custom-base-urls/ Point a provider at an endpoint you control instead of the provider's own API. A provider that authenticates with its own **API key** can carry an optional **Base URL** that replaces the provider type's own origin. Nothing else changes: the path after the slug, the query string, the credential header, the model rules and the pricing rule are exactly what they would have been. ## The Base URL field In the **Add provider** dialog, with **Authentication** set to **API key**, fill in **Base URL (optional)**. Leave it empty to use the provider's own API. The field takes an absolute `https://` URL, and the path you append after the slug is joined onto it: ```text Base URL: https://my-vllm.example.com/ Your app: POST /v1/apps/my-app/proxy/vllm/v1/chat/completions Reaches: https://my-vllm.example.com/v1/chat/completions ``` Click **Test provider** to run the provider type's test call at the new origin before saving. A custom endpoint that does not implement the provider's list-models call answers 404, which reports as unconfirmed rather than as a refusal. The API equivalent: ```json POST /v1/admin/providers { "type": "openai", "name": "Self-hosted Llama", "slug": "vllm", "secret": "...", "baseUrl": "https://my-vllm.example.com/" } ``` The field is not available with **Use gateway**. A gateway owns the upstream origin, and the API refuses `baseUrl` together with `providerGatewayId` on both create and update. The base URL and client path are joined literally. Do not put `v1/` in both. For an OpenAI-compatible server, the layout above also makes **Test provider** call `/v1/models` and lets custom endpoints use their standard `v1/` paths. The gateway's default inference policy matches the client path, before this join; a custom base URL does not change that policy. ## What it enables Any server that speaks a supported provider's API unchanged becomes usable without a code change in your apps. - **Self-hosted inference.** vLLM, SGLang, Ollama behind TLS, llama.cpp's server. Pick `openai` as the type and use the origin as the base; keep `v1/` in the client path. - **A second region of a supported provider.** BytePlus ModelArk's `eu-west` host, for instance, whose keys and catalogs are region-isolated. Pick `bytedance` as the type and enter that region's base URL. - **A proxy of your own** in front of a provider, as long as it speaks that provider's API unchanged. - **Azure OpenAI**, with the caveats [below](#azure-openai). The console shows the origin under **Auth** in the Providers list, beside the key's last characters, so it is never a surprise which service a key is being sent to. ## What the URL must look like The URL is checked and rewritten into a canonical form before it is stored. Each rule refuses with a message naming what is wrong. | Rule | Rejected examples | | --- | --- | | `https` only | `http://...`, `ftp://...` | | No credentials in the URL | `https://user:pass@host/` | | No query string and no fragment | `https://host/?api-version=...`, `https://host/#x` | | Default port only (443, explicit or implicit) | `https://host:8443/` | | A public DNS name, never an IP address in any form | `https://127.0.0.1/`, `https://0x7f000001/`, `https://[::1]/` | | At least two labels, valid DNS labels, no trailing dot | `https://vllm/`, `https://host./`, `https://a..b.example.com/` | | No reserved local suffix | `.local`, `.localhost`, `.internal`, `.home.arpa`, `.lan`, `.corp`, `.test`, `.example` | | At most 200 characters | | What is stored is the canonical form: lower-cased host (punycoded if you typed Unicode), no redundant `:443`, dot segments resolved, and exactly one trailing slash. `https://My-VLLM.Example.com/v1` is stored as `https://my-vllm.example.com/v1/`. Upstream **redirects are never followed** on any proxied request. A `3xx` from your endpoint is passed back to the client as is, so a redirect cannot move the destination after the URL was checked. ## Changing the origin To move an existing provider to a different origin, open **Update key** on its row. The dialog has a **Base URL** field next to **New API key**, with the hint "Changing the origin needs the key above, because the stored key is never sent to a new origin. Leave empty to use the provider's own API." That rule is the point. The stored key is write-only: it is never decrypted to be sent somewhere it has not been sent before, so pointing a provider at a new host cannot become a way to read its key back out. Paste the key again, set the new **Base URL**, and click **Update key**. Emptying the field returns the provider to the provider type's own API. Through the API, a new `baseUrl` needs `secret` in the same request, and the update is refused with `400 invalid_request` without it. Clearing needs nothing else: ```json PUT /v1/admin/providers/{id} { "secret": "sk-...", "baseUrl": "https://eu.example.com/v1/", "revision": 7 } ``` ```json PUT /v1/admin/providers/{id} { "baseUrl": null, "revision": 7 } ``` Send the same `type`, `secret` and `baseUrl` to `POST /v1/admin/providers/test` first to find out whether the key works at the new origin. ## Security note > **Pointing a provider at an origin hands that origin the key** > > The base URL is set only by you, through the console or a > [management key](https://docs.appaigateway.com/automation/api). No app and no end user can influence it; > it is read from the stored provider, never from a request. Treat entering an > origin as equivalent to giving that origin the provider's API key, because > that is what every request does. The checks above stop the direct expression of a mistake: a local address, a typo, a copied internal name. They cannot stop a public hostname from resolving to a private address, because the gateway does not resolve names itself and cannot see or pin the address a request connects to. The real control is that only you can write this field. ## Custom endpoints still need prices A model served from your own endpoint is almost certainly not in the shipped catalog, so it is refused with `400 pricing_not_configured` until you enter a price for it under the provider's **Pricing** action. Entering the price is what allows the model. There is no "unmetered because it is mine" path. For a model you host yourself and pay no per-token price for, `$0` is enterable. Enter it deliberately, because it also removes that traffic from every [spending limit](https://docs.appaigateway.com/applications/limits). See [Model pricing](https://docs.appaigateway.com/providers/model-pricing). ## Azure OpenAI Use provider type `openai`. Microsoft's SDK examples include `/openai/v1/` in the [SDK base URL](https://learn.microsoft.com/en-us/azure/developer/ai/how-to/switching-endpoints). For this gateway, put `/openai/` in the provider's **Base URL** and keep `v1/` in the client path: ```text Base URL: https://YOUR-RESOURCE-NAME.openai.azure.com/openai/ Client path: v1/chat/completions Upstream: https://YOUR-RESOURCE-NAME.openai.azure.com/openai/v1/chat/completions ``` Azure's v1 API accepts the resource key as `Authorization: Bearer`, which is what the gateway sends for the `openai` type. The gateway's credential probe also appends `v1/models`. Two things to know: - **The older, dated Azure surface is not supported.** It authenticates with an `api-key` header and needs an `?api-version=` query on the base, neither of which the gateway adds. The URL check refuses a base URL carrying a query string for that reason. Use the v1 base URL above. - **Microsoft Entra ID tokens are not supported.** They are short-lived and need refreshing, and a provider stores one static secret. Use a resource key. The `model` field in your requests is your **deployment name**, not the upstream model name, and deployment names are not in the shipped catalog. Price them on that provider as described above. --- # Gateways Source: https://docs.appaigateway.com/providers/gateways/ Route providers through your Cloudflare AI Gateway or Vercel AI Gateway instead of holding their keys here. A **gateway** is a connection to somebody else's AI gateway, stored once and used by as many providers as you like. Two types are supported: **Cloudflare AI Gateway** and **Vercel AI Gateway**. A provider routed through a gateway carries no provider key of its own. The gateway's token authenticates it, and the provider keys live on the gateway's side. Gateways are optional. A provider with its own **API key** calls the provider directly and never touches one. ## Add a gateway Open **Providers**, then **Gateways** in the sidebar, and click **Add gateway**. You can also reach the same dialog from the **Add provider** dialog by choosing **Use gateway** and then **New gateway...** in the **Gateway** picker, so the provider you were describing is not thrown away. | Field | Cloudflare AI Gateway | Vercel AI Gateway | | --- | --- | --- | | **Gateway type** | Cloudflare AI Gateway | Vercel AI Gateway | | **Name** | Your label, defaults to `Our CF gateway` | Your label, defaults to `Our Vercel gateway` | | **Cloudflare Account ID** | Required | Not shown | | **Cloudflare Gateway ID** | Required | Not shown | | **Gateway token** | The gateway's own authentication token | An AI Gateway API key | The **Gateway token** field links to the provider's instructions for creating one. Under it, the dialog states the credential policy for the chosen type, quoted in [Whose key pays](#whose-key-pays) below. Click **Test gateway** to check the connection, then **Add gateway**. The token is encrypted on arrival and never shown again; the list displays its last four characters under **Auth**, next to the Cloudflare account and gateway IDs where there are any. Saving does not call the gateway, so a connection can be stored while the Cloudflare side of it is still being built. The API takes exactly the fields the type needs and rejects any other: ```json POST /v1/admin/provider-gateways { "type": "cf_aig", "name": "Prod CF gateway", "accountId": "...", "gatewayId": "...", "token": "..." } ``` ```json POST /v1/admin/provider-gateways { "type": "vercel", "name": "Prod Vercel gateway", "token": "..." } ``` ## Test gateway **Test gateway** probes the connection with the fields as entered. Nothing is stored. For Cloudflare it calls OpenAI's list-models path through your gateway, so it proves the account ID, the gateway ID and the token at once. For Vercel it calls Vercel's own authenticated credits endpoint, which rejects a key that is not real. | Verdict | What it says | | --- | --- | | Works | "Works. The gateway accepted this token." | | Failed | "The gateway refused this token (HTTP 401). Check the token itself, that authentication is turned on, and that the gateway holds a key for OpenAI." | | Failed | "The gateway answered with HTTP 404. Check the account and gateway IDs." | | Unconfirmed | "The gateway did not answer in time, so nothing is proven either way." | A Cloudflare AI Gateway answers 401 both for a wrong token and for a gateway whose authentication or stored key is not set up yet. Only you can tell those apart, which is why a refusal never stops you from saving. ```json POST /v1/admin/provider-gateways/test { "type": "cf_aig", "accountId": "...", "gatewayId": "...", "token": "..." } ``` The answer is `{ "validated": true }` or `{ "validated": false, "reason": "rejected" | "unreachable" | "unexpected_status", "status": 401 }`. A refusal is reported, not raised as an error. ## Attach a provider A gateway carries no traffic on its own. Attach providers to it one at a time: open **Add provider**, set **Authentication** to **Use gateway**, and pick the gateway. The **API key** and **Base URL** fields disappear, because the provider's own key lives in the gateway and the gateway owns the origin. The slug rules are the same as for any provider, so a second OpenAI provider needs its own slug, for example `openai-cf`. See [Add a provider key](https://docs.appaigateway.com/providers/add-a-provider). ```json POST /v1/admin/providers { "type": "openai", "name": "OpenAI via CF", "slug": "openai-cf", "providerGatewayId": "..." } ``` Each gateway serves only the provider types verified against its live catalog: | Gateway | Provider types | | --- | --- | | Cloudflare AI Gateway | `openai`, `anthropic`, `xai`, `gemini`, `perplexity` | | Vercel AI Gateway | `openai`, `anthropic`, `gemini`, `xai`, `perplexity`, `deepseek`, `moonshot` | Attaching any other type is refused with `400 provider_not_supported_by_gateway` rather than stored as a provider whose every request would fail. Connect those types with their own API key. The full list is in [Supported providers](https://docs.appaigateway.com/providers/supported-providers). ## Paths on each route A direct provider uses the provider's own paths. A gateway-routed provider uses its gateway's paths, and the two gateways differ. **Cloudflare AI Gateway** forwards to each provider's own API under a per-provider slug, so every API that provider has survives the trip, native ones included. Use the provider's own paths from [Supported providers](https://docs.appaigateway.com/providers/supported-providers): Gemini's `v1beta/models/{model}:generateContent` works on a Cloudflare-routed Gemini provider. Requests go to `https://gateway.ai.cloudflare.com/v1////...`. **Vercel AI Gateway** publishes one URL space for every provider and names the provider in the model ID instead. Only three paths exist, whatever the provider type: | Client API | Path after the slug | | --- | --- | | OpenAI Responses | `v1/responses` (POST only) | | OpenAI Chat Completions | `v1/chat/completions` | | Anthropic Messages | `v1/messages` | Anything else on a Vercel-routed provider is refused with `403 api_style_not_supported` before the request leaves the gateway. In particular, Gemini's native `generateContent` is not available through Vercel (use a direct or Cloudflare-routed Gemini provider for that), and neither are transcription, image generation or embeddings, so a Vercel-routed provider serves only Responses, Chat Completions and Anthropic Messages [custom endpoints](https://docs.appaigateway.com/applications/custom-endpoints). The Responses entry means `POST v1/responses` only: retrieving, cancelling or deleting a stored response by ID is refused on a Vercel-routed provider, because Vercel does not document those operations. Keep that work on a direct or Cloudflare-routed provider. The gateway never translates between API formats. Calling OpenAI Chat Completions on a Gemini-via-Vercel provider sends an OpenAI body to Vercel, and Vercel translates it once. ## Model IDs stay canonical Everywhere you can see a model, it is the provider's own ID, exactly as on a direct provider: an app's allowed models and fixed models, custom pricing, model rewrites, and recorded usage all say `gemini-2.5-flash`, never `google/gemini-2.5-flash`. On a Cloudflare route the ID is passed through unchanged. On a Vercel route the gateway adds Vercel's namespace on the way out and strips it from anything Vercel echoes back, so one price row covers a model on every route and moving an app between routes changes no configuration. | Provider type | Vercel namespace | Example wire model | | --- | --- | --- | | `openai` | `openai/` | `openai/gpt-5.6-sol` | | `anthropic` | `anthropic/` | `anthropic/claude-opus-5` | | `gemini` | `google/` | `google/gemini-2.5-flash` | | `xai` | `spacexai/` | `spacexai/grok-4.5` | | `perplexity` | `perplexity/` | `perplexity/sonar-pro` | | `deepseek` | `deepseek/` | `deepseek/deepseek-v4-pro` | | `moonshot` | `moonshotai/` | `moonshotai/kimi-k3` | The app's [provider access](https://docs.appaigateway.com/applications/provider-access) page states the rule per provider, for example "Gemini model IDs with no `google/` prefix; the gateway adds it upstream." ### Routing options for Vercel Two per-provider settings exist for Vercel-routed providers. Both are set through the API, on `gatewayRoute` at create time or on `PUT /v1/admin/providers/{id}` (a full replace; `null` clears it). - `modelPrefix` overrides the namespace from the table above. Use it if Vercel names one of your models differently from the provider. It must end with `/`. - `providerOnly` pins the serving provider. Vercel may serve one model from several upstreams (Claude from Anthropic, Bedrock or Vertex). The values are Vercel's own provider slugs, and the gateway sends them as `providerOptions.gateway.only` in the outbound body. That is a routing directive, not a translation: the payload the serving provider eventually sees is the one your client sent. ```json PUT /v1/admin/providers/{id} { "gatewayRoute": { "providerOnly": ["vertex", "anthropic"] }, "revision": 7 } ``` Cloudflare AI Gateway takes no routing configuration. Sending `gatewayRoute` on a Cloudflare-routed provider is refused with `400 invalid_request`. ## Whose key pays The two gateways treat provider keys differently, and the Add gateway dialog says so under the token field. **Cloudflare AI Gateway**: "Requests use the provider keys stored in your Cloudflare AI Gateway's own key store." The gateway sends only its token and metadata; no provider auth header travels on that route, because your Cloudflare gateway supplies it. Usage events record `credential_source: "byok"`. **Vercel AI Gateway**: "Your provider credential stored in Vercel is preferred. Vercel may fall back to system credentials." Vercel documents your own keys as preferred and documents falling back to its own when yours fail. Nothing at configuration time settles which one paid for a request, so usage events routed through Vercel record the credential source as unknown rather than claiming it was yours. Vercel charges provider list price with no token markup, so the shipped [price catalog](https://docs.appaigateway.com/providers/model-pricing) stays accurate. Cost on both routes is computed locally from the catalog or your custom prices. See [Usage](https://docs.appaigateway.com/applications/usage) for the route and credential-source breakdowns. ## Update token One token authenticates every provider routed through a gateway. To rotate it, open **Update token** in the gateway's row. The dialog reads "One token authenticates every provider routed through this gateway, so all 3 of them pick the new one up within a minute." Paste the **New gateway token**, click **Test token** if you want it checked against the stored account and gateway IDs, then **Update token**. The **Update key** action on a routed provider's own row is greyed out for this reason, and the API answers `409 provider_gateway_managed` there. ```json POST /v1/admin/provider-gateways/{id}/rotate { "token": "...", "revision": 4 } ``` ## Rename and delete **Rename** changes the display name only. As the dialog says, nothing about the connection or the providers routed through it moves. ```json PATCH /v1/admin/provider-gateways/{id} { "name": "Prod CF gateway", "revision": 4 } ``` Both updates use the `revision` returned by the gateway list. A `409 conflict` means the gateway changed after that read; read it again before retrying. **Delete gateway** destroys the stored token. It is only available once nothing routes through the gateway, and the confirm dialog says so: "Nothing routes through it, so no traffic changes." While any provider references the gateway, the action is greyed out with the reason. Disabled providers count, because a paused provider keeps its reference so it can be enabled again: - "Delete every active provider instance routed through this gateway first" - "Delete the active and disabled provider instances routed through this gateway first" - "Disabled provider instances still reference this gateway; delete them to release it" The API answers `409 gateway_in_use` with the same message. Each gateway in `GET /v1/admin/provider-gateways` carries `providerCount` (active providers routed through it) and `referencedCount` (every provider referencing it, disabled ones included). Deletion is governed by the second number. ```text DELETE /v1/admin/provider-gateways/{id} ``` ## Headers on each route Which headers travel upstream follows the route. Your apps never send a provider key or a gateway token; the gateway adds the right one. - **Direct to the provider.** The provider's own auth header and no gateway control header of any kind. - **Through Cloudflare AI Gateway.** `cf-aig-authorization` and `cf-aig-metadata` (the app ID and the end user ID), set by the gateway. A client may set the documented `cf-aig-cache-ttl`, `cf-aig-skip-cache`, `cf-aig-max-attempts`, `cf-aig-backoff` and `cf-aig-retry-delay` controls. Every other `cf-aig-*` header is stripped. - **Through Vercel AI Gateway.** `Authorization: Bearer ` plus Vercel's `ai-reporting-user` and `ai-reporting-tags`, set from the end user and the app. A client value in any `ai-reporting-*` header is stripped, since it would rewrite your Vercel-side spend attribution. `x-api-key` is stripped too, because Vercel accepts a key there and would let it outrank the gateway's own token. A gateway's own headers are reserved on every route, so a client value in `cf-aig-authorization`, `authorization`, `x-api-key` or `ai-reporting-*` never reaches an upstream. Upstream URLs are always built from the stored, non-secret gateway configuration; clients never supply one. The full list of forwarded and dropped headers is in [Calling the proxy](https://docs.appaigateway.com/integrate/proxy). --- # Model pricing Source: https://docs.appaigateway.com/providers/model-pricing/ How the gateway prices each request, how to add prices for models the catalog does not know, and why unpriced models are refused. The gateway records provider attempts and their measured cost in USD. Before forwarding, it requires either a local model price or a route that reports its own cost, such as xAI or OpenRouter. A successful response without readable usage is marked **unresolved**, so recorded spend can differ from your provider's invoice. This page covers local prices, reported cost and unresolved usage. ## The catalog The gateway ships with a price catalog: input and output prices per million tokens for the common models of each provider type, plus cached-input, cache-write and long-context rates where the provider has them, per-minute or per-hour rates for transcription models, and per-image rates for a few image models. Prices are market data about models rather than something you configure, so the catalog ships with the gateway. The hosted service updates it with gateway releases. Self-hosted gateways receive catalog updates when you update your deployment. Use the provider's **Pricing** action to cover a new or changed model without waiting for a release. The catalog is keyed by provider type and the provider's own model ID: `gpt-5.6-sol` under `openai`, `claude-opus-5` under `anthropic`, `gemini-2.5-flash` under `gemini`, `grok-4.5` under `xai`. A model priced in the catalog works on every provider of that type, direct or routed through a gateway, because [model IDs stay canonical](https://docs.appaigateway.com/providers/gateways#model-ids-stay-canonical) on every route. Every catalog entry also says what the model does: `text`, `image`, `speech`, `transcription`, `embedding` or `realtime` (a model served only over a realtime connection). A [custom endpoint](https://docs.appaigateway.com/applications/custom-endpoints) offers and accepts only models of its own kind. A model you price yourself with the **Pricing** action counts as a text model; a catalog model you re-price keeps its kind. Two provider types ship with no catalog at all, because no static list could be right for them: **Fireworks AI** names models per account, and **Hugging Face** re-prices the same model ID on every upstream its router might pick. Their models proxy once you price them, and not before. ### Prompt cache writes Anthropic charges more for a cache write kept for an hour than for one kept the default five minutes: 2x the input price against 1.25x. Its responses say how many written tokens were kept for each, and the gateway bills each share at its own rate (`cache_write_1h` and `cache_write` in the catalog). If a response gives no split, every written token bills at the five-minute rate. ### Image generation models The catalog covers image models that are priced per token, with separate rates for image tokens where the provider charges them differently from text: - **OpenAI** GPT Image models, such as `gpt-image-2` and `gpt-image-2.5-sunburst`. Prompt text and input images (in an edit) each bill at their own input rate, and generated images at the image output rate. - **Gemini** image models, such as `gemini-3-pro-image` and `gemini-nano-banana-2.1`. Generated image tokens bill at the image output rate, and text and thinking at the text rate. Both providers report how many of a request's tokens were images, and the gateway bills each kind at its own rate. If a response does not break its tokens down, all of them bill at the model's highest rate, so an image is never billed at the cheaper text rate. The same applies when part of a prompt was served from cache: the response does not say whether the cached tokens were text or images, so the uncached tokens are billed as images first. The recorded cost can then be above what the provider charged, but never below. A model billed a flat price per image is in the catalog only when its response says how many images it billed. Today that is **BytePlus Seedream**: `dola-seedream-5-0-flash-260915`, `seedream-5-0-lite-260128`, `seedream-4-5-251128` and `seedream-4-0-250828`. Each one bills its price times the images the response counts in `usage.generated_images`, which leaves out images that failed or were withheld by moderation, as BytePlus's own bill does. If a response does not count its images, the event is marked unresolved rather than recorded at $0. Other image models are not in the catalog when their price depends on more than a count of images, such as size, quality, steps or a provider's estimate. That covers Together AI's image models, Seedream 5.0 pro (two pixel tiers, a charge for input images and per-layer tiers) and Gemini 2.5 Flash Image. Custom prices are per token, so they cannot bill these models either, and the models stay unsupported. The exception is a provider that reports its own cost: xAI's image models proxy with no price (see [Providers that report their own cost](#providers-that-report-their-own-cost)). ### Treat recorded cost as usage accounting Catalog prices estimate the provider cost from the usage it reports. Discounts, serving tiers, tools and other provider-specific charges may not be represented by a model's token rates. Check your provider's current pricing and invoice when exact charges matter, and use custom prices where appropriate. `GET /v1/admin/prices` shows the built-in catalog this gateway uses; the provider's **Pricing** dialog shows your custom overrides. Model names alone do not tell you their relative price, and a catalog entry does not guarantee that your provider account can still use it. ## Custom prices A model the catalog does not know, or knows at a stale price, is unblocked by adding it to the provider's custom pricing. Custom prices belong to one provider: a model priced only there is usable through that provider alone, which is why the model pickers in an app's proxy policy are per slug. Open **Providers**, open the row's actions menu, and choose **Pricing**. The dialog is titled **Custom model pricing** and says: "For models the built-in catalog does not cover, or prices it in a way you disagree with. Requests for unpriced models are rejected until a price is set here. Enter $0 for a model that is genuinely free." Click **Add model** for each row and fill in its columns: | Column | Meaning | | --- | --- | | **Model** | The model ID exactly as your app sends it, for example `gpt-brand-new`. | | **Input $/1M** | Price per million input tokens. | | **Output $/1M** | Price per million output tokens. | | **Cached $/1M** | Optional. Price per million input tokens served from the provider's prompt cache. | | **Cache write $/1M** | Optional. Price per million input tokens written to the provider's prompt cache. | Then **Save pricing**. Every row needs a model name and the input and output prices, as numbers of zero or more. A cache price left blank bills at the input price. A model cannot appear twice. A row left entirely blank is dropped, so a spare one does no harm. Custom prices are ordinary data, not secrets, so the dialog shows the stored values and anyone who can read the console can see them. Lookup has two levels: the provider's own custom price for the requested model first, then the shipped catalog. A custom price replaces the catalog's rates for that model on that provider only. It does not inherit any of them: a rate you leave out bills as it would on a catalog entry without it (a cache rate at the input price, a long-context rate at the normal one), never at the catalog's figure. If the provider gives a cache discount, enter it, or cached traffic records at the full input price. If you priced a model yourself before the catalog covered it, such as a GPT Image model, delete that custom price so the catalog's full rates apply. The API equivalent sets `pricing` on the provider. It is a full replace, keyed by model ID, and `null` clears every custom price: ```json PUT /v1/admin/providers/{id} { "pricing": { "gpt-brand-new": { "input": 1.25, "output": 10, "cached_input": 0.125 } }, "revision": 7 } ``` Through the API, a custom price accepts every per-token rate the catalog uses, under the same names as in `GET /v1/admin/prices`: `cached_input`, `cache_write`, `cache_write_1h`, the image, audio and video rates, and the `long_*` rates. The `long_*` rates also need `long_context_threshold`, the prompt size in tokens above which they apply. The dialog shows the cache rates and keeps the others as they are when you save. The same field is accepted on `POST /v1/admin/providers` when you create the provider. ### Free models `$0` is a valid price, and it is the right one for a model you host yourself and pay no per-token price for. Enter it deliberately: a request priced at zero counts toward nothing in any [spending limit](https://docs.appaigateway.com/applications/limits), so a free model still records requests and tokens, but contributes no spend. Request limits still apply. The dialog refuses an empty price field for that reason; zero has to be a typed answer. ## Unpriced models are refused A request for a model that neither the provider's custom pricing nor the catalog knows is refused before it reaches the provider: ```json { "error": { "code": "pricing_not_configured", "message": "..." } } ``` The status is `400`, and the message names where to fix it. Entering the price is what allows the model on a locally priced route. This applies to custom base URLs too: a model served from your own endpoint is almost certainly not in the catalog and needs a price on that provider. See [Custom base URLs](https://docs.appaigateway.com/providers/custom-base-urls#custom-endpoints-still-need-prices). ## Providers that report their own cost Two provider types report what they charged instead of relying on a local price: - [xAI](https://docs.appaigateway.com/providers/supported-providers#xai), on its Chat Completions, Responses and image generation answers. - [OpenRouter](https://docs.appaigateway.com/providers/supported-providers#openrouter), on its chat-completions answers, the only API it serves here. The gateway records that figure, so on those APIs their models proxy with **no local price**; the rule is "priced locally **or** reported upstream". A report counts only where the answer reaches the gateway as the provider wrote it: directly, or through Cloudflare AI Gateway. Through Vercel, an xAI model bills on its local price like any other. The **Pricing** dialog says which of a provider's APIs need a price. For an OpenRouter provider: "OpenRouter reports the cost of every request, so its models proxy with no price here; a price entered here is only used if a response ever comes back without one." For an xAI provider, it names the APIs xAI reports on and says that its other APIs still need a price. A custom price on such a provider is honoured as the fallback for a response that arrives without a cost. ## Where a cost comes from Every usage event records how its cost was arrived at, in `cost_source`: | `cost_source` | Meaning | | --- | --- | | `reported` | The provider said what the request cost, and that figure is the recorded one. xAI and OpenRouter report one. | | `computed` | Priced locally, from the provider's custom pricing or the shipped catalog. | | `unresolved` | Neither source could answer on a successful response. | Reported cost wins because it is what you were charged. Local pricing is the default and the fallback: if a cost-reporting route ever answers without a cost, a local price for that model is used and the event is `computed`. An event is `unresolved` when the provider answered successfully but the gateway could not measure the spend. That happens when a provider reports usage in a shape the gateway does not know, when a cost-reporting route answers without a cost and the model has no local price, when a price was removed while a request was already in flight, or when a client aborts a streamed response before the final chunk that carries the usage. Such an event is shown as **unresolved** rather than `$0.00` in the app's event list and can be counted with the **By cost source** breakdown. Its cost is zero because nothing was measurable, not because nothing was spent. See [Usage](https://docs.appaigateway.com/applications/usage) for the breakdowns and what a rising unresolved count means. > **Deleting a provider deletes its custom prices** > > Custom prices live on the provider. **Update key** keeps them; **Delete > provider** discards them along with the key. Usage events outlive the > provider, so past traffic whose serving provider was deleted can only be > repriced if the built-in catalog covers it. Recreating the same slug creates a new provider; > its custom prices do not reconnect to the deleted provider's events. ## Repricing stored usage If a price was wrong for a while, `POST /v1/admin/apps/{app}/usage/reprice` recomputes the stored cost for one provider type, model and month. Each event is priced through the provider that served it, falling back to the catalog, so two providers of one type can reprice the same model differently. Run it with `"apply": false` first: the preview reports `matched_events` (what would be rewritten), `unpriced_events` (events whose serving provider was deleted and which no catalog price covers) and `unmetered_events` (events with no readable usage, which are rewritten to zero but keep their `unresolved` marker). With `"apply": true` the call is strict: if any matched event cannot be priced, it answers `400 invalid_request` and writes nothing, so a month is never left half-repriced. Events with a `reported` cost are never touched, since a local price would replace a fact with an estimate. A repriced event's `cost_source` becomes `computed` when readable usage was available. Repricing covers retained raw events only, with at most 10,000 matching events per call, and requires token pricing. It does not reprice compacted history, time-based transcription charges or per-image charges. See the [API reference](https://docs.appaigateway.com/api) for the request shape. --- # Create an application Source: https://docs.appaigateway.com/applications/create/ Walk through the New app wizard for an iOS app or a server, and what it creates. Open **Apps** and click **New app**. The wizard asks only what it needs for the type of app you pick. You can edit its policy later on the app's own pages. Its ID is permanent, and the settings pages do not change its application type. ## 1. Basics - **Application name**: how the app is listed. The gateway builds the app's permanent ID from it, for example `Calorie Tracker` becomes `calorie-tracker-k3f9x1`. See [Apps and providers](https://docs.appaigateway.com/how-it-works/apps-and-providers#the-app-id-is-permanent). - **Application type**: - **iOS application**: calls AI providers straight from the app. The app proves itself with Apple App Attest. - **Server**: a backend you run, using a private API key. ## 2. Application identity (iOS only) Only builds signed with your team and bundle ID can call the gateway through this app. - **Apple Team ID**: in your Apple Developer account under Membership details. - **Bundle ID**: in Xcode, on your target's Signing & Capabilities tab. - **iOS app environment**: **Production** covers TestFlight and App Store builds. **Development** covers builds run from Xcode. Keep Development on a development bundle ID rather than the one you ship. See [Application identity](https://docs.appaigateway.com/applications/auth-policy/identity). ## 3. User authentication Whether requests are tied to a user, and how the gateway knows who that is. For an iOS app: - **Signed-in users only**: users sign in through your identity provider, and the gateway verifies it. - **Unauthenticated users**: anyone with your app can call. Each installation counts as one user. For a server: - **Signed-in users only**: your backend forwards each user's sign-in token, and the gateway verifies it. - **Your backend sends the user id**: in the `x-end-user-id` header on every request. Choosing it shows a **Header name** field, where you can name a different header. - **No user identity**: requests are not linked to any user. If you do not have your identity provider's details yet, the step offers to continue without them so you can set this up later. ## 4. Identity provider (signed-in users only) Pick where your users sign in. The preset fills in the verification settings from one or two values you already have. | Preset | You enter | | --- | --- | | **Firebase Authentication** | Firebase project id | | **Supabase Auth** | Project ref | | **Auth0** | Auth0 domain, API identifier | | **Clerk** | Frontend API host, Audience | | **Custom issuer** | JWKS URL, Issuer (iss), Audience (aud) | Each preset has a note about what its provider needs. See [User authentication](https://docs.appaigateway.com/applications/auth-policy/user-authentication). ## 5. Subscription check (signed-in users only) Whether the user must have paid before they can call AI providers through this app. - **Any signed-in user**: signing in is enough. Nothing about payment is checked. - **Paid users only**: the sign-in token must carry a claim that says the user has paid. Then choose which claim: - **RevenueCat entitlement**: enter the entitlement identifier, for example `pro`. Several, comma separated, admit a user carrying any one of them. - **Custom claim**: one or more rows of **Path**, **Match** and **Value**, edited the same way as on the app's own Subscription check page. See [Subscription check](https://docs.appaigateway.com/applications/auth-policy/subscription-check). ## What is created Click **Create app**. The new app allows every provider you have, has no model rewrites and no custom endpoints, and is active. - An **iOS application** starts with per-user limits of 100 requests per minute and 10,000 per day, and no spending budget. - A **server** application starts with no limits at all. A server application also gets its first API key, shown once in a dialog titled **Your application is ready**, together with the base URL your backend will call. Copy the key before closing the dialog. More keys can be created later on the app's **Auth policy** page. Then you land on **Provider access**, where you can narrow which providers, inference paths and models the app may use, or explicitly allow another provider path. The defaults are ready for inference, so that step is optional. ## Through the API `POST /v1/admin/apps` creates the same thing. The body carries `name`, `config` and an optional `status`, never an `id`. The response returns the app with its assigned `app.id`, and `api_key.key` for a server application. A server app with no user identity: ```json { "name": "Search service", "config": { "authentication": { "type": "api_key", "end_user": { "source": "none" } }, "routing": { "providers": { "mode": "all" }, "model_rewrites": {} } } } ``` An iOS app with signed-in users from Firebase and the default limits: ```json { "name": "Calorie Tracker", "config": { "authentication": { "type": "apple_app_attest", "app_attest": { "team_id": "ABCDE12345", "bundle_id": "com.example.calorietracker" }, "end_user": { "source": "issuer", "issuer": { "jwks_url": "https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com", "issuer": "https://securetoken.google.com/my-app-1a2b3", "audience": "my-app-1a2b3", "user_id_claim": "sub", "required_claims": [], "max_token_lifetime_seconds": 86400, "provider": "firebase" } } }, "routing": { "providers": { "mode": "all" }, "model_rewrites": {} }, "limits": { "per_user": { "requests": { "per_minute": 100, "per_day": 10000 }, "spending": { "monthly_usd": null } }, "per_app": { "requests": { "per_minute": null, "per_day": null }, "spending": { "monthly_usd": null } } } } } ``` Every key is described in the [application configuration reference](https://docs.appaigateway.com/automation/agent-manual#application-configuration-reference). --- # Application identity Source: https://docs.appaigateway.com/applications/auth-policy/identity/ How an app proves it is your app, with App Attest for iOS or API keys for a server. The **Auth policy** page has three levels, and **Application identity** is the first. It answers one question: is this request coming from my app? An iOS application answers with Apple App Attest. A server answers with an API key. The level shows its state at a glance: **Verified with App Attest**, **Team or bundle id missing**, **Verified with API keys**, or **No active API key**. ## iOS applications Only builds signed with your team and bundle ID can call AI providers through this app. - **Apple Team ID**: found in your Apple Developer account under Membership details. - **Bundle ID**: on your target's Signing & Capabilities tab in Xcode. Apple binds an App Attest key to one install of one app on one device, and the attestation carries a hash of your team ID and bundle ID. The gateway checks that hash, stores the key's public half, and from then on accepts assertions signed by that key: one at each token exchange and, unless the app verifies App Attest on the token exchange only, one on every request. The key never leaves the Secure Enclave. Keys are per install. A signed-in user who uses your app on a phone and a tablet holds two keys, and a user who reinstalls the app gets a new one; registering a key never replaces another. Each install is counted on its own by the [per-install limits](https://docs.appaigateway.com/applications/limits). ### iOS app environment Apple runs App Attest in two environments and stamps which one into every attestation. An app accepts one or both, and at least one must stay on. - **Production**: builds signed for TestFlight and the App Store. On by default. - **Development**: builds run from Xcode, which attest in Apple's development environment. Off unless you turn it on. A development-signed build is debuggable and runs on any device carrying your team's provisioning profile, which is why Development is off by default and why the console warns when you turn it on. Put the opt-in on a development bundle ID, never on the one you ship. Removing an environment also stops the keys it already admitted: on their next request when the app verifies every request, otherwise at their next token exchange, within the hour. The refusal is `403 attest_failed`, and the app registers a fresh key in an environment the app still accepts. Two things no setting changes. App Attest is unavailable in the Simulator, because it needs the Secure Enclave. And a development build can be made to attest in production instead by setting the `com.apple.developer.devicecheck.appattest-environment` entitlement to `production`, which is the better answer when a development build only needs to reach a production-only app. ### Verify App Attest on When the gateway checks the install's App Attest assertion. - **Every request**: each request carries a fresh App Attest assertion, so a gateway token copied off the network is useless without the device. Requires the AppAIGateway Swift package 1.4 or later. The default for new apps. - **Token exchange**: the assertion is checked once per hourly token, and requests carry only the token. Apps created before this setting was added were set to **Token exchange**, so their existing installs keep working. Switch to **Every request** once your clients are on the Swift package 1.4 or later: from then on a request without a proof is refused with `401 assertion_required`, which is what every build on an older package receives. Request limits, budgets and usage are counted per install either way. See [When App Attest is verified](https://docs.appaigateway.com/how-it-works/identity#when-app-attest-is-verified). ### In the configuration ```json "authentication": { "type": "apple_app_attest", "app_attest": { "team_id": "ABCDE12345", "bundle_id": "com.example.app.dev", "environments": ["production", "development"], "verify_on": "every_request" }, "end_user": { "source": "app_install" } } ``` `environments` may be omitted, which means `["production"]`. `verify_on` is `every_request` or `token_exchange`, and may be omitted, which means `every_request`. `end_user` is required on an iOS app and is covered under [User authentication](https://docs.appaigateway.com/applications/auth-policy/user-authentication). ## Server applications A server application proves itself with one of its API keys. The card lists every key with its **Name**, **Prefix**, **Last used** and **Status**. **New key** asks for a **Key name** so you can tell keys apart, then shows the key once in a dialog titled **Copy your new key now**. The gateway stores only a hash, so there is no way to see the key again. Store it in your backend's secret manager. **Revoke** stops a key within a minute. Create the replacement first, move your backend to it, then revoke the old one, and rotation costs no downtime. Once revocation has propagated, bare-key requests using those keys fail with `401 auth_required`. Gateway tokens issued from a revoked key also stop working within a minute. The app's pages show a notice when no active key remains. ### How the key is sent Without signed-in users, your backend sends the key as the bearer credential on every request: ```http Authorization: Bearer agw_… ``` With signed-in users, the key is exchanged together with the user's sign-in token for a gateway token instead, and bare keys are refused on requests. See [Server applications](https://docs.appaigateway.com/integrate/server). ### In the configuration ```json "authentication": { "type": "api_key", "end_user": { "source": "none" } } ``` `end_user` is required here too. `none` means the app has no end users; the other sources are covered under [User authentication](https://docs.appaigateway.com/applications/auth-policy/user-authentication). Keys are not part of the configuration. `POST /v1/admin/apps/{app}/keys` creates one and returns `key` once; `POST /v1/admin/apps/{app}/keys/{key}/revoke` revokes it. --- # User authentication Source: https://docs.appaigateway.com/applications/auth-policy/user-authentication/ Tie requests to signed-in users from Firebase, Supabase, Auth0, Clerk or your own issuer, or count each install as a user. **User authentication** is the second level of the **Auth policy** page. It decides whether requests belong to a user and how the gateway knows who that is. Identifying users is what makes per-user limits, per-user usage and blocking possible. The state line says which choice is active: **Signed-in users only**, **Identity provider not finished**, **Your backend names the user**, **Header name missing**, **Unauthenticated users allowed**, or **No user identity**. ## The choices | Choice | App type | User ID | | --- | --- | --- | | **Signed-in users only** | iOS and server | A claim from a verified sign-in token, `sub` by default | | **Unauthenticated users** | iOS | The App Attest key ID of the install | | **Your backend sends the user id** | Server | The value of a header your backend sends | | **No user identity** | Server | None | ## Signed-in users only Your app sends a signed JWT from its identity provider, with the issuer and audience you configure below. The gateway verifies the signature against the provider's public keys, checks the issuer and audience against what you configured, and takes the user ID from the token. Pick your provider under **Identity provider**. The preset fills in the verification settings from the values you enter. | Preset | You enter | What it configures | | --- | --- | --- | | **Firebase Authentication** | Firebase project id (from Project settings, not the project number) | Google's shared key set, issuer `https://securetoken.google.com/`, audience `` | | **Supabase Auth** | Project ref (the subdomain of your project URL) | Your project's JWKS, issuer `https://.supabase.co/auth/v1`, audience `authenticated` | | **Auth0** | Auth0 domain, API identifier | Your Auth0 domain's JWKS, issuer `https:///`, audience = the API identifier | | **Clerk** | Frontend API host, Audience | Your Frontend API JWKS, issuer `https://`, audience = what your JWT template sets | | **Custom issuer** | JWKS URL, Issuer (iss), Audience (aud) | Exactly what you enter | Things each provider needs you to know: - **Firebase** signs every project's tokens with one shared key set, so the key set alone identifies nobody. The issuer and audience are what scope the app to your project. Without them a token from any Firebase project in the world would be accepted, which is why both are required. - **Supabase** must be on asymmetric JWT signing keys. A legacy HS256 project publishes no public keys, so every token is rejected. - **Auth0** puts a trailing slash in `iss`. The preset writes it. Do not remove it. With the API identifier as audience, send a JWT access token requested for that API, not an ID token whose audience is your client ID. See [Get Access Tokens](https://auth0.com/docs/secure/tokens/access-tokens/get-access-tokens). - **Clerk**'s default session token has no `aud`. Add one in your JWT template, because the gateway requires it. Clerk's own guidance also checks `azp` against permitted origins; native apps have no origin, so the preset omits it. - **Sign in with Apple** works as a custom issuer: issuer `https://appleid.apple.com`, audience your bundle ID, JWKS URL `https://appleid.apple.com/auth/keys`. Issuer and audience each accept one value or a list, so a migration between two projects or bundle IDs is a configuration edit rather than a cut-over. A stored configuration missing either value does not run: the console reports it as unfinished, and token exchanges fail until it is fixed. ### What the app sends The app exchanges its proof and the ID token for a gateway token at `POST /v1/apps/{app}/auth/token`, then uses the gateway token on requests. The Swift package does this for you. See [iOS with the Swift package](https://docs.appaigateway.com/integrate/ios) and [Server applications](https://docs.appaigateway.com/integrate/server). A refused exchange answers with one of three codes, chosen by what the app should do next: | Code | Meaning | The app should | | --- | --- | --- | | `403 issuer_token_rejected` | The token did not verify: bad signature, expired, wrong issuer or audience, or no user ID claim | Get a fresh token from the identity provider, retry once, then fail | | `403 issuer_claims_missing` | The token verified, but a required claim from the subscription check is not there yet | Wait and retry. Do not sign the user in again | | `503 issuer_verification_unavailable` | The gateway could not reach the provider's key set, so the token was never judged | Retry with backoff | Every outcome is listed on the app's [Auth & Errors](https://docs.appaigateway.com/applications/auth-events) page. ### In the configuration ```json "end_user": { "source": "issuer", "issuer": { "jwks_url": "https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com", "issuer": "https://securetoken.google.com/my-app-1a2b3", "audience": "my-app-1a2b3", "user_id_claim": "sub", "required_claims": [], "max_token_lifetime_seconds": 86400, "provider": "firebase" } } ``` `user_id_claim` names the claim used as the user ID. `max_token_lifetime_seconds` rejects a token whose own lifetime, expiry minus issue time, is longer than that, so a provider misconfigured to issue week-long tokens cannot widen the window. `provider` and `entitlement` are bookkeeping for the console so it can reopen the same preset; the gateway ignores them. `required_claims` is the subscription check. ## Unauthenticated users For an iOS app with no sign-in. The App Attest key identifies the install, so per-install limits, blocks and usage all work with nothing self-reported. The app sends no ID token, and one sent anyway is refused rather than ignored. The key lives in the Secure Enclave for one install of one app on one device. It does not survive reinstalling the app, clearing its data, or moving to a new phone, so a user can shed the limits and blocks attached to an install by starting over. And there is no token to inspect, so the subscription check is unavailable. ```json "end_user": { "source": "app_install" } ``` ## Your backend sends the user id For a server that fronts real users and knows who they are. Your backend names the user in a header on every request, and the gateway takes the value on trust. **Header name** defaults to `x-end-user-id`. It is matched case-insensitively and removed before the request reaches the provider. Values are 1 to 128 printable ASCII characters. The header is required once configured. A request without it is refused with `400 invalid_request`, because an app that tolerated its absence would silently meter part of its traffic as nobody. This belongs only on a server you control. A client an end user runs could send any ID it likes and bypass per-user limits. Block these users in your backend; gateway blocking requires an authenticated user or install. ```json "end_user": { "source": "header", "header": "x-end-user-id" } ``` ## No user identity For a server with no users to speak of, such as a batch job. Requests record no user, per-user limits cannot be set, and `GET /v1/apps/{app}/me` answers `404`. Use the application limits instead. `end_user` is required on an `api_key` app, so an app with no users says so with the `none` source. Naming any other source is how an app opts into having users at all. ```json "end_user": { "source": "none" } ``` --- # Subscription check Source: https://docs.appaigateway.com/applications/auth-policy/subscription-check/ Require that a signed-in user has paid before they can call AI providers through the app. **Subscription check** is the third level of the **Auth policy** page. It requires the user's sign-in token to carry a claim that says they have paid. It is available only with **Signed-in users only**, because it needs a token to inspect. The state line says **Paid users only**, **Any signed-in user**, **Paid check not finished**, or **Needs signed-in users**. ## How it works The gateway never talks to the App Store. It checks a claim inside the verified ID token, and something else has to put that claim there after the purchase: - **RevenueCat** with its Firebase extension writes the user's active entitlements into their Firebase token as `revenueCatEntitlements`. - **Your own backend** can set any custom claim on your identity provider after it confirms a purchase. On every token exchange the gateway verifies the token as usual and then checks the required claims. A token without them is refused with `403 issuer_claims_missing`. ## Choices - **Any signed-in user**: signing in is enough. Nothing about payment is checked. - **Paid users only**: the sign-in token must carry a claim that says the user has paid. Then choose which claim: - **RevenueCat entitlement**: enter the **Entitlement identifier** from RevenueCat's Product catalog, the identifier rather than the display name. Several, comma separated, admit a user carrying any one of them. - **Custom claim**: one or more rows of **Path**, **Match** and **Value**. Paths are dot paths into the token. **contains** matches arrays and space-delimited scopes; **equals** compares exactly. ## The propagation gap The claim is written after the purchase, by a webhook or a backend job, and the app may refresh its token before that has happened. During that window a paying user holds a perfectly valid token that lacks the claim. This is why the refusal has its own code. `issuer_claims_missing` means "the user is real, the entitlement has not arrived yet, wait and try again". Treating it as a sign-in failure would send the user round a loop they cannot escape. The Swift package exposes a hook that runs only on this code, so you can trigger a purchase sync there. See [iOS with the Swift package](https://docs.appaigateway.com/integrate/ios). The gateway measures the gap. The app's [Auth & Errors](https://docs.appaigateway.com/applications/auth-events) page shows how long activation usually takes and how many users are waiting on a claim right now. ## In the configuration The check is the `required_claims` list on the issuer block. A RevenueCat entitlement named `pro`: ```json "required_claims": [ { "path": "revenueCatEntitlements", "contains": "pro" } ], "entitlement": "revenuecat" ``` A custom claim requiring a scope: ```json "required_claims": [ { "path": "scope", "contains": "ai.invoke" } ] ``` Each requirement has a `path` and either `contains` (a string or a list of alternatives) or `equals` (a string, number or boolean). All requirements must pass. `entitlement` is bookkeeping for the console and may be omitted. --- # Provider access Source: https://docs.appaigateway.com/applications/provider-access/ Choose which providers, paths and models an app may use, and cap its output tokens. The **Provider access** page decides which of your providers an app may call, and what it may ask each of them for. A new app may use every provider you have, its inference endpoints and every priced model. Narrow it here or explicitly allow another provider path. The page opens with one field, **Providers this app can call**, then lists your providers, each with its name, the URL segment clients use, `/proxy//…`, and what the app may ask it for in one line, such as "All inference endpoints · all models · no output cap": - **All providers**: every provider you have is allowed, and a provider you add later is available to this app automatically. Every row is marked allowed; a disabled provider carries a **disabled** mark and serves nothing until it is enabled again. - **Selected providers**: the app may use only the providers whose switch is on. A provider you add later stays off until you turn it on here. Choosing **Selected providers** starts with every provider switched on, so nothing changes for the app until you turn one off. An enabled row opens with the chevron at its end to reveal the three settings below, and its line changes to say what you set, such as "1 endpoint · 1 model · up to 4,096 output tokens". ## Endpoints this provider serves **All inference endpoints** allows every endpoint that runs a model and answers in the same response: - Responses, Chat Completions, Anthropic Messages, Gemini `generateContent`, and transcription, on every provider. - Each provider's own image generation, embedding, speech and legacy completion endpoints, listed below. Nothing else is allowed by default: files, batches, fine-tuning, model lists, moderation, token counting and agent sessions stay blocked. So do video generation and other jobs whose result arrives on a later poll, because the gateway forwards only POST requests and cannot fetch or bill that result. A model still needs a price, or a provider that reports its own cost, before any request reaches it. | Provider | Endpoints beyond the default APIs | | --- | --- | | OpenAI | `v1/images/generations`, `v1/images/edits`, `v1/embeddings`, `v1/audio/speech` | | xAI | `v1/images/generations`, `v1/images/edits`, `v1/embeddings`, `v1/completions` | | Gemini | `v1beta/models/{model}:embedContent`, `v1/models/{model}:embedContent` | | Perplexity | `v1/agent`, `router/v1/chat/completions`, `router/v1/responses`, `router/v1/messages`, `v1/embeddings`, `v1/contextualizedembeddings` | | DeepSeek | `responses`, `beta/completions`, `anthropic/v1/messages` | | Groq | `openai/v1/audio/speech` | | Mistral | `v1/embeddings`, `v1/fim/completions`, `v1/audio/speech` | | Together AI | `v1/completions`, `v1/images/generations`, `v1/audio/speech`, `v1/embeddings` | | Fireworks AI | `inference/v1/completions`, `inference/v1/messages`, `inference/v1/responses`, `inference/v1/embeddings` | | Cerebras | `v1/completions` | | Moonshot AI | `anthropic/v1/messages` | | ByteDance | `responses`, `images/generations`, `embeddings/multimodal` | | Anthropic, Hugging Face, Baseten, OpenRouter | None | The console lists these under the field, too. **Max output tokens** applies to the text endpoints among them, such as completions and the Anthropic-format APIs, as it does to the default APIs. Image, embedding and speech endpoints are never capped, because they have no output tokens to cap. A provider routed through a gateway that publishes its own URL space, such as Vercel, serves only the APIs that gateway carries. Choose **Selected endpoints** to allow only the ones you list, which is also how you opt into any other provider operation. A list replaces the defaults, so list every path the app calls. The list starts with one empty row; removing the last row is the same as choosing all again. A request that asks the provider to run detached, with `"background": true` or xAI's `"deferred": true`, is refused with `400 invalid_request` on every endpoint. Its result and cost arrive only on a later poll, which the gateway cannot serve. An endpoint is named by its path: the provider's own API path without a leading slash, for example `v1/responses` or `v1/messages`. Paths are matched exactly, except that `{model}` in a path matches one segment, which is how Gemini's `v1beta/models/{model}:generateContent` names its model in the URL. Each row has two optional settings: - **Fixed model** applies to routes with no model in the body, such as transcription, and is used for pricing and the model allowlist. It is never injected into the request. - **Output cap style** tells the gateway which body field carries the output limit for this path when it applies **Max output tokens**. Leave it on **auto** unless the route needs an exception. The styles are `responses`, `chat_completions`, `gemini_native`, `anthropic` and `none`. A request for a path not in the list answers `403 path_not_allowed`. ## Models this provider serves **All models** allows every model that has a price. Choose **Selected models** and add model names to allow only those; removing the last one is the same as choosing all again. [Model rewrites](https://docs.appaigateway.com/applications/model-rewrites) are applied after this check, so the client name is what you list here. A request for another model answers `403 model_not_allowed`. Model names are the provider's own, on every route: `gemini-2.5-flash`, never `google/gemini-2.5-flash`. OpenRouter is the exception, where the model name includes its author prefix, as OpenRouter itself names it. ## Max output tokens Empty means unrestricted. When set, a request that asks for more output than this is refused with `403 max_output_tokens_exceeded`, and a request that does not name an output limit gets this value injected. Transcription routes ignore the cap. ## Embeddings Embedding endpoints are among the default inference endpoints. An embedding has no output to cap, so the gateway injects no limit into a request to `v1/embeddings` (or Fireworks' `inference/v1/embeddings`) or to Gemini's `embedContent`, even when you list the path yourself. Any other embedding path you list needs `"clamp": "none"`, or the injected limit makes the provider refuse it. ```json "openai": { "allowed_paths": ["v1/responses", "v1/embeddings"], "allowed_models": [] }, "gemini": { "allowed_paths": [ "v1beta/models/{model}:generateContent", "v1beta/models/{model}:embedContent" ], "allowed_models": [] }, "perplexity": { "allowed_paths": ["chat/completions", { "path": "v1/contextualizedembeddings", "clamp": "none" }], "allowed_models": [] } ``` Gemini Embedding 2 bills images, audio and video at their own rates, above the text rate. `embedContent` reports what each request contained, so call it there: a response that does not say, such as one from the OpenAI-compatible endpoint, is billed entirely at the video rate. ## When a provider is missing An app may name a provider you have since deleted or never added. The app's pages show a banner naming the missing providers, requests to that slug answer `502 provider_not_configured` (or `502 provider_disabled` for a paused one), and every other provider keeps working. Under **Selected providers** the row for a missing slug is marked **deleted** and says no provider has it any more, and you can turn it off or recreate the provider. ## In the configuration ```json "routing": { "providers": { "mode": "selected", "selected": { "openai": { "allowed_paths": [ "v1/responses", { "path": "v1/audio/transcriptions", "fixed_model": "gpt-4o-mini-transcribe", "clamp": "none" } ], "allowed_models": ["gpt-5.6-sol", "gpt-5.6-terra"], "max_output_tokens": 4096 }, "anthropic": { "allowed_paths": [], "allowed_models": [] } } }, "model_rewrites": {} } ``` `mode` is `all` or `selected`. With `selected`, the keys of `selected` are provider slugs, and a slug not listed is not allowed. An entry with empty lists allows the default inference endpoints and every priced model of that provider. A non-empty `allowed_paths` replaces that default. When you create an app, every slug you name must exist; when you update one, only slugs the update introduces are checked, so removing a provider never blocks unrelated edits. --- # Model rewrites Source: https://docs.appaigateway.com/applications/model-rewrites/ Point a model name an app sends at the model the provider should receive. The **Model rewrites** page maps a model name the client sends to the model the provider receives. The rewrite is applied after the allowlist check, so the target does not need to be in the [allowed models](https://docs.appaigateway.com/applications/provider-access#models-this-provider-serves), and usage records the rewritten model. A rewrite is how you move an app to a newer model without a release: the app keeps asking for `gpt-5.6-terra` and you point that name at `gpt-5.6-sol`. For a bigger change, such as a different provider or fixed parameters, use a [custom endpoint](https://docs.appaigateway.com/applications/custom-endpoints). Rewrite targets must be covered by the built-in catalog or one of your providers' pricing rules. A catalogued target can be saved before its provider exists. A request still needs a price, or upstream cost reporting, on the provider it actually calls. ## In the configuration ```json "routing": { "model_rewrites": { "gpt-5.6-terra": "gpt-5.6-sol" } } ``` `model_rewrites` maps the model name a client sends to the one the provider receives. Both sides are the provider's own model names. --- # Custom endpoints Source: https://docs.appaigateway.com/applications/custom-endpoints/ Keep the provider, model, parameters and fallbacks on the gateway behind a stable slug, so you can change them without an app release. A custom endpoint is a URL of your own whose provider and model live in the app's configuration instead of in the client: ```text POST https://api.appaigateway.com/v1/apps/{app}/endpoints/{slug} ``` The client sends only the request body. You decide, and can change at any time, which provider and model serve it, which parameters are fixed, and what to fall back to when the provider fails. Custom endpoints ignore provider access: this configuration is the policy. ## Adding one On the app's **Custom endpoints** page, click **Add endpoint**. Every provider type can serve one, in the request formats listed below. - **Slug**: 1 to 64 characters from `a-z`, `0-9` and `-`. This is the last segment of the URL, so pick a name for what the endpoint does, such as `chat` or `transcribe`, not for the model behind it. - **What it does**: text generation, image generation, speech (text to speech), transcription (speech to text) or embeddings. The model pickers only offer models of this kind, and the gateway refuses a model of another kind on save. - **Request format**: the body your clients send. Only formats that carry the chosen kind, and that one of your providers serves, are offered. - **Provider**: one of your providers, by slug, that serves the format. - **Model**: swap it at any time; clients keep calling the same slug. Only models with a price are accepted, except where the provider reports its own cost for the format (xAI, OpenRouter): there **Other model ID…** takes any model it serves. - **Parameters** (JSON formats): JSON deep-merged over the client body, with the server winning on conflicts. Leave `{}` for none. Use it for what every client should send, such as an image size or a voice. - **Max output tokens** (text only): same rule as in provider access. A client value above it is refused, a missing value is injected. - **Fallback chain**: provider and model pairs tried in order when the provider call fails, is rate limited upstream, or returns a server error, and nothing has been streamed to the client yet. Each provider attempt is recorded, including a failed attempt before a successful fallback. A fallback must speak the same format, so it can be another provider of the same type or any other provider that serves the format. ## Kinds and request formats | What it does | Request format (`api_style`) | Providers | | --- | --- | --- | | Text generation | OpenAI Responses (`responses`) | OpenAI, xAI | | | OpenAI Chat Completions (`chat_completions`) | DeepSeek, Groq, Mistral, Together AI, Fireworks AI, Cerebras, Moonshot AI, Hugging Face, Baseten, ByteDance, Perplexity, OpenRouter | | | Anthropic Messages (`anthropic_messages`) | Anthropic | | | Gemini generateContent (`gemini_native`) | Gemini | | Image generation | OpenAI image generation (`image_generation`) | OpenAI, xAI, ByteDance (Seedream) | | | Gemini generateContent (`gemini_native`) | Gemini image models (Nano Banana) | | Speech (text to speech) | Gemini generateContent (`gemini_native`) | Gemini TTS models | | Transcription (speech to text) | OpenAI audio transcription (`audio_transcription`) | OpenAI, xAI, Groq | | Embeddings | OpenAI embeddings (`embeddings`) | OpenAI | | | Gemini embedContent (`gemini_embedding`) | Gemini | Each provider type serves a kind through one format, so a model of the right kind on that provider always works in it. OpenAI and xAI text endpoints use Responses, which every one of their text models supports. Models that only work over a realtime connection, such as `gpt-live-transcribe`, cannot serve a custom endpoint. A provider routed through Vercel AI Gateway serves only Responses, Chat Completions and Anthropic Messages endpoints, because those are the only APIs Vercel publishes. OpenAI endpoints call `v1/audio/transcriptions`, xAI endpoints call `v1/stt` and Groq endpoints call `openai/v1/audio/transcriptions`. ## What the client sees The request is authenticated exactly like a proxy request: a gateway token or an API key, plus `X-App-Version` for token holders. Limits, usage accounting and the error format are identical. The response keeps the serving provider's own format and streams unbuffered. An unknown slug answers `404 endpoint_not_found`. See [Calling custom endpoints](https://docs.appaigateway.com/integrate/endpoints). ## Fallback across keys Because targets are addressed by provider slug, a fallback chain can name two providers of the same type. `openai` falling back to `openai-dev` moves traffic to a second key without changing the request shape. A fallback target that cannot be resolved at request time, because the provider is gone, disabled or unpriced, is skipped rather than failing the request. Only the primary target must resolve, except that a disabled primary is skipped so its fallbacks can serve. Deleting the primary provider, losing its credential, or removing its price fails the request before fallback. The whole chain shares a 120-second budget for response headers. A primary that uses the entire budget leaves no time for a fallback; a stream that has already begun is never restarted on another provider. The **Usage** page's **By endpoint** breakdown shows what each endpoint cost, and the event list shows which target served each request. ## In the configuration ```json "endpoints": { "chat": { "api_style": "responses", "provider": "openai", "model": "gpt-5.6-luna", "params": { "reasoning": { "effort": "low" } }, "max_output_tokens": 4096, "fallback": [{ "provider": "xai", "model": "grok-4.5" }] }, "draw": { "api_style": "image_generation", "provider": "openai", "model": "gpt-image-2", "params": { "size": "1024x1024" } }, "voice": { "kind": "speech", "api_style": "gemini_native", "provider": "gemini", "model": "gemini-3.8-flash-tts", "params": { "generationConfig": { "responseModalities": ["AUDIO"] } } }, "transcribe": { "api_style": "audio_transcription", "provider": "openai", "model": "gpt-4o-mini-transcribe" } } ``` `kind` is what the endpoint does: `text`, `image`, `speech`, `transcription` or `embedding`. Most formats carry one kind, and the gateway fills it in. `gemini_native` carries text, image and speech, so give it a `kind` unless the endpoint generates text. A saved configuration always shows `kind`. Every model in the block, fallbacks included, must be of the endpoint's kind and have a price in the catalog or in that provider's custom pricing, or the configuration is rejected on save. A model you price yourself counts as a text model. A model with no price is accepted only on a provider that reports its own cost, and that provider decides whether it serves it. --- # Limits Source: https://docs.appaigateway.com/applications/limits/ Cap how many requests and how much spend each install, each user and the whole app may have. The **Limits** page sets the limits you place on your app's own callers and on the app as a whole. Edits take up to a minute to apply everywhere. ## Per-install and per-user limits On an iOS app the card is called **Per-install limits**; on a server app, **Per-user limits**. An install is one copy of your app on one device: reinstalling the app starts a new one, and a person who uses the app on two devices is two installs. - **Requests per minute**: per install on an iOS app, per user on a server app. - **Requests per day**: the same. - **Monthly spending budget (USD)**: per install on an iOS app unless you choose otherwise, per user on a server app. - **Budget measured per** (iOS apps only): **Install**, the default, or **User**. **User** adds up every install of one signed-in person, and is available only when the app uses **Signed-in users only**; with **Unauthenticated users** every install is its own user. The switch is yours to make: nothing changes it for you. Rate limits answer `429 app_rate_limited`. An exhausted budget answers `429 app_budget_exhausted`. Both carry a `Retry-After` header and `data.scope`: `install` for a request limit on an iOS app or an install budget, `user` for a server app's limits or a budget measured per user. The budget is settled from completed requests, so it stops a request within about ten seconds of the one that crosses it. Spend is recorded per install and per user alike, so switching the budget's scope part-way through a month counts what the month has already spent. This card needs an app that identifies its callers. For an app with **No user identity** its fields are disabled, and a notice at its top links to the **Auth policy** page where user authentication is turned on, because there is nobody for a per-user limit to apply to until then. ## Per-app limits Shared across all installs, users and keys of the app. The same three fields, with `data.scope: "app"` on refusals. An `app` refusal is everybody's traffic, so a client backing off one user will not clear it. Leave a field empty to keep it unrestricted. A budget of `0` is a closed door that stops all billable traffic for that scope; a request limit of `0` is refused as a likely typo. See [Limits and blocking](https://docs.appaigateway.com/how-it-works/limits) for how the windows and months are counted. ## In the configuration ```json "limits": { "per_user": { "requests": { "per_minute": 30, "per_day": 1000 }, "spending": { "monthly_usd": 10, "scope": "install" } }, "per_app": { "requests": { "per_minute": null, "per_day": null }, "spending": { "monthly_usd": 100 } } } ``` The block is optional: omit it and the app is unlimited. Every field is nullable, and `null` means unlimited for that field alone. Non-null per-user limits on an app with no user identity are refused; an all-null block is accepted. The key stays `per_user` on every app type. `spending.scope` is `install` or `user`. It defaults to `install` on an iOS app and to `user` on a server app, and the gateway writes the default in when it stores the configuration. `user` on an iOS app needs `end_user.source` `issuer`, and `install` on a server app is refused: a server app has no installs. --- # Users Source: https://docs.appaigateway.com/applications/users/ See who is using the app, what each user has spent, and block a user who is running up your bill. The **Users** page lists every user the app has identified, with their requests, tokens and cost for the selected month. On an iOS app with **Unauthenticated users** the page is called **Installs**, because there every user is one install and its ID is the install's App Attest key ID. A row appears once a client completes its first token exchange, or, for a server that names users in a header, its first request. ## The list - **User**: the user ID, from the sign-in token, the App Attest key, or your backend's header. A blocked user carries a `blocked` badge. - **Installs**, on an iOS app with signed-in users: how many installs the user holds, one per device and one more for each reinstall. - **Attestation**, elsewhere: `registered` for an App Attest install, `issuer token` for a user verified by sign-in token, or `server attributed` for a header-named user. - **Last seen**, **Requests**, **Tokens**, **Cost** for the selected month. Search by user ID, filter by **All users**, **Active** or **Blocked**, and change the month with the picker. Pages hold 25 users. ## Blocking **Block** refuses every request from the user, on every install they hold. New token exchanges are refused immediately with `403 auth_required`, and a gateway token the user already holds stops working within ten seconds. **Unblock** restores them the same way. A block is not a limit. Limits reset; a block stays until you lift it. A user identified only by an app install can shed a block by reinstalling the app, because that creates a new install identity. Users identified by a sign-in token cannot. Users your backend names in a header are listed from their usage and cannot be blocked here, because the gateway never authenticates them. Stop them in your backend instead. ## Through the API - `GET /v1/admin/apps/{app}/users` with optional `month`, `status`, `query`, `limit` and `offset`. Each user carries `installs`, the number of App Attest keys they hold, which is `0` on a server app. - `GET /v1/admin/apps/{app}/users/{user}`. - `POST /v1/admin/apps/{app}/users/{user}/block` and `…/unblock`. --- # Usage and events Source: https://docs.appaigateway.com/applications/usage/ Spend, tokens and provider attempts over time, with detailed usage events. The **Usage** page shows what the app has done. The **Apps** list summarises the same numbers for the month. ## Daily usage by provider A stacked daily chart for the last 7, 30 or 90 days, by **Cost**, **Requests** or **Tokens**, one colour per provider. ## Breakdown A table over the same range, grouped by one dimension: | Dimension | Answers | | --- | --- | | **By model** | Which models cost what | | **By model author** | Spend on Google's, OpenAI's or Anthropic's models, on any route | | **By provider** | Which provider type served the traffic | | **By gateway** | Traffic through Cloudflare or Vercel AI Gateway versus direct | | **By credential source** | Your own key versus a gateway's key | | **By user** | Who spent what. On an iOS app, offered when its budget is measured per user | | **By install** | Which install spent what: one copy of an iOS app on one device. Offered on iOS apps whose budget is measured per install, the default | | **By status** | Served requests and provider errors | | **By cost source** | Whether cost was computed locally, reported by the provider, or unresolved | | **By route** | Provider slug and path | | **By endpoint** | Each custom endpoint | | **By app version** | The `X-App-Version` header, so you can see whether a problem is one release | Columns are **Requests**, **Total input**, **Cached input**, **Cache write**, **Output** and **Cost**. **Total input** counts every prompt token, including those read from or written to the provider's prompt cache, so the two cache columns are shares of it. Model, provider and status breakdowns reach back over the whole history. The other dimensions come from the raw event log, which is kept for a bounded period; totals are never lost. ## Recent events Every provider attempt, newest first, with **When**, **User · key** (on an iOS app, **User · install**, naming the install beside the user), **Model**, **Route**, **Tokens**, **Cost**, **Latency** and **Status**. The route shows the endpoint slug where one was used, the gateway it went through, and for OpenRouter the provider that actually served it. Filter by status: | Status | Meaning | | --- | --- | | `ok` | Served by the provider | | `provider_error` | The provider refused or failed the request | Requests stopped before contacting a provider appear as samples in [Auth & Errors](https://docs.appaigateway.com/applications/auth-events). They do not add usage requests, tokens or cost. Provider errors still appear here because the provider was contacted; a fallback request can have more than one provider attempt. ## Where a cost comes from Each event records how its cost was arrived at. - **computed**: priced locally from the catalog or the provider's custom pricing. This is the normal case. - **reported**: the provider said what the request cost, and that figure is used. xAI and OpenRouter do this. - **unresolved**: the provider answered successfully but reported no usage the gateway could read, so the request consumed no budget. Shown as **unresolved** rather than `$0.00`. The usual cause of an unresolved event is a client that disconnected from a stream before the final chunk carrying the usage arrived. The generation stops when the client disconnects, but what was generated up to then is unmetered. A sustained count of unresolved events on one app or user is worth a look, and per-user rate limits bound how much can go unmetered. See [Model pricing](https://docs.appaigateway.com/providers/model-pricing). ## Through the API - `GET /v1/admin/apps/{app}/usage?month=YYYY-MM` for totals. - `GET /v1/admin/apps/{app}/usage/timeseries?from=&to=` for the daily chart. - `GET /v1/admin/apps/{app}/usage/breakdown?by=model&from=&to=` where `by` is one of `model`, `model_author`, `provider`, `provider_slug`, `provider_gateway`, `credential_source`, `user`, `install`, `status`, `cost_source`, `route`, `endpoint`, `app_version`. - `GET /v1/admin/apps/{app}/events?limit=&status=&user=&install=&model=&before_id=` for the event list, paged by `next_before_id`. Each event carries `install_id`, the install's App Attest key id on an iOS app and `null` on a server app. --- # Auth and errors Source: https://docs.appaigateway.com/applications/auth-events/ Token exchange success, failures by cause, and how long paid entitlements take to arrive. The **Auth & Errors** page shows how the app's clients are getting on with authentication, and which requests failed and why. ## The numbers - **Exchange success**: the share of token exchanges that succeeded, with the number of attempts. - **Claim delay p50** and **p95**: how long users wait between their first refusal for a missing entitlement claim and the exchange that finally succeeds. Only apps with a [subscription check](https://docs.appaigateway.com/applications/auth-policy/subscription-check) have this. - **Pending activations**: users waiting on a claim right now. ## Failures by cause Refused authentication attempts, provider errors, and sampled requests stopped before a provider call, busiest first, with **Outcome**, **Reason**, **Count**, **Days affected** and **Last seen** over the chosen range. This is the first place to look when a release starts failing. Blocked requests are sampled once per authenticated identity per minute across the deployment, regardless of route or reason. Their counts are labeled **samples** and are not exact request totals. Authentication failures and provider errors are recorded as individual events. ## Recent refusal samples The list shows the time, user or API key, reason, scope, attempted route and model, app version, and admission latency. The attempted route describes what would have been called; the gateway stopped the request before contacting it. Filter by reason, scope, or user, then page through older samples. An older sample may have an unknown scope when its original record did not preserve it. ## Recent attempts Every token exchange and key registration, newest first: **When**, **User**, **Call** (`token_exchange` or `register`, with the auth method), **Outcome**, **Reason**, **App version**, **Latency**, and **Claim delay** where the exchange ended a wait. Outcomes: | Outcome | Meaning | | --- | --- | | `ok` | The exchange succeeded | | `issuer_token_rejected` | The sign-in token did not verify | | `issuer_claims_missing` | The token verified but a required entitlement claim is not there yet. Amber, because it is a wait, not a failure | | `issuer_verification_unavailable` | The identity provider's key set could not be fetched. Amber, transient | | `service_unavailable` | The infrastructure the gateway runs on failed, or the exchange took longer than 10 seconds. **Reason** is `platform_unavailable`, `platform_overloaded` or `deadline_exceeded`. Amber, transient: worth retrying | | `internal_error` | Something failed inside the gateway itself | | `attest_failed` | The App Attest attestation or assertion did not verify, or the key's environment is no longer accepted | | `auth_required` | No usable credential, or a blocked user | The **Reason** column carries the granular cause. It is recorded here and never sent to the client, so a client sees only the outcome code and what to do about it. ## Through the API - `GET /v1/admin/apps/{app}/auth-events/summary?days=30` - `GET /v1/admin/apps/{app}/auth-events?limit=&outcome=&before_id=` - `GET /v1/admin/apps/{app}/rejection-events?limit=&reason=&scope=&user=&from=&to=&before_id=` Auth events and refusal samples are kept for 90 days. --- # App settings Source: https://docs.appaigateway.com/applications/settings/ Rename an app, turn it off so it refuses every request without losing anything, or delete it, in the console or through the API. The app's **Settings** page holds the few things that are not policy. ## Name and ID **Name** can be changed at any time. **Application id** cannot: it was fixed when the app was created and is part of every URL your clients call. Use **Copy** to grab it. ## App enabled Turned off, the app refuses every request with `403 app_disabled`. Nothing is deleted, and turning it back on restores it as it was. Use it to stop a misbehaving release or a runaway client while you investigate. ## Delete this app Clients of the app stop working within a minute as cached configuration and credentials expire. Registered users, application keys, App Attest keys and authentication diagnostics are removed. Usage history is retained in account totals. The confirmation asks you to type the app ID. ## Through the API - `PUT /v1/admin/apps/{app}` with the full body updates the name, the configuration and the status. Include the `revision` from your latest read and preserve `status` (`active` or `disabled`); omitting status enables the app. - `DELETE /v1/admin/apps/{app}?confirm={app}` deletes the app. The query value must repeat the app ID. --- # iOS with the Swift package Source: https://docs.appaigateway.com/integrate/ios/ Add the AppAIGateway package, pick an auth mode, and send provider requests from your app with no key on the device. The AppAIGateway Swift package does the part of the integration that is easy to get wrong: App Attest registration, the token exchange, token refresh, signing each request with App Attest when your app verifies every request, and the headers every request needs. You keep sending provider requests with `URLSession` or any HTTP client. The package builds and signs the request, and decides when to build it again; your code sends it. Requirements: iOS 16 or later, a real device for App Attest, and an app in the console of type **iOS application** with your Team ID and Bundle ID. ## Add the package In Xcode, choose **File → Add Package Dependencies** and enter: ```text https://github.com/maxceem/app-ai-gateway-swift ``` Or in `Package.swift`: ```swift .package(url: "https://github.com/maxceem/app-ai-gateway-swift", from: "1.6.0") ``` Add the `AppAIGateway` product to your target and `import AppAIGateway`. The package is developed in the `app-ai-gateway-swift` directory of the [main repository](https://github.com/maxceem/app-ai-gateway), which is where to report issues. ## Create the client ```swift import AppAIGateway let gateway = AppAIGatewayClient( appID: "example-app-a1b2c3", baseURL: URL(string: "https://api.appaigateway.com")!, authMode: .appAttestInstall ) ``` `appID` is the app's ID from the console. `baseURL` is `https://api.appaigateway.com` for the hosted service. > **Self-hosting** > > Set `baseURL` to your own gateway's API origin, without `/v1` or an app path. > The package appends those paths itself. Keep one client per app for the life of the process; it caches the gateway token and refreshes it in the background. ## Choose the auth mode The mode must match the app's **User authentication** setting in the console. | Console setting | Auth mode | | --- | --- | | **Unauthenticated users** | `.appAttestInstall` | | **Signed-in users only** | `.appAttest(issuerTokenProvider:)` | ### Unauthenticated users ```swift authMode: .appAttestInstall ``` App Attest alone. The install is the user. No sign-in is involved and no ID token is sent. ### Signed-in users Give the client a closure that returns the user's current ID token from your identity provider. The `forceRefresh` flag is set when the gateway rejected the previous token, so pass it through to your provider's refresh call. ```swift import FirebaseAuth authMode: .appAttest(issuerTokenProvider: { forceRefresh in guard let user = Auth.auth().currentUser else { throw NotSignedIn() } return try await user.getIDToken(forcingRefresh: forceRefresh) }) ``` Return the JWT whose issuer and audience match your gateway configuration: Firebase's ID token, Supabase's session access token, Clerk's configured JWT, or your own. With the Auth0 preset, return an access token issued for your API identifier, as described in [Auth0's token guide](https://auth0.com/docs/secure/tokens/access-tokens/get-access-tokens). The closure is named `issuerTokenProvider` regardless of the token type. ## Send a request `withAuthorizedRequest` builds a `URLRequest` with the URL, the body, the `Authorization` header, `X-App-Version` and the request's App Attest proof set, and hands it to your closure to send. Pass the body to it, and send the request your closure receives. ```swift let (data, response) = try await gateway.withAuthorizedRequest( provider: .openai, providerPath: "v1/responses", body: try JSONEncoder().encode([ "model": "gpt-5.6-sol", "input": "Summarise today's meals in one sentence." ]), contentType: "application/json" ) { try await URLSession.shared.data(for: $0) } if let error = GatewayError(response: response as! HTTPURLResponse, body: data) { throw error } ``` The package never performs the request itself, so any session, configuration or networking library works in the closure. If the gateway answers `401`, the client discards its token, builds the request again with a fresh token and, when your app verifies every request, a fresh proof, and calls your closure once more. That recovers from an expired gateway token or one that names no install (`401 auth_required`), and from an app switched to **Every request** after the token was cached (`401 assertion_required`). Any `401` is retried without its body being read, so one a provider returned through the proxy, such as for a rejected provider key, costs one extra exchange and one repeated provider call. An error your closure throws, and every other status, `403 attest_failed` and `429` included, come back to you untouched. `provider` is a provider slug. The constants `.openai`, `.anthropic`, `.xai`, `.gemini` and `.perplexity` spell the default slug of each type; use `.custom("openai-dev")` for any other. `providerPath` is the provider's own API path with no leading slash. Headers the provider needs, such as `anthropic-version` for Anthropic, can be added to the request inside the closure; the proof does not cover them. See [Calling the provider proxy](https://docs.appaigateway.com/integrate/proxy) for the paths and headers of each provider. To stream, send with `URLSession.bytes(for:)`. It returns once the response headers arrive, before the body, so the status is known while the stream is still unread. Read the provider's stream as documented by the provider: ```swift let (bytes, response) = try await gateway.withAuthorizedRequest( provider: .anthropic, providerPath: "v1/messages", body: Data(#"{"model":"claude-sonnet-5","max_tokens":512,"stream":true,"messages":[{"role":"user","content":"Plan tomorrow's meals."}]}"#.utf8), contentType: "application/json" ) { request in var request = request request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") return try await URLSession.shared.bytes(for: request) } let http = response as! HTTPURLResponse guard (200..<300).contains(http.statusCode) else { let body = try await bytes.reduce(into: Data()) { $0.append($1) } throw GatewayError(response: http, body: body)! } for try await line in bytes.lines { print(line) } ``` A [custom endpoint](https://docs.appaigateway.com/integrate/endpoints) is the same call with a slug: ```swift let (data, response) = try await gateway.withAuthorizedRequest( endpointSlug: "chat", body: Data(#"{"input":"Suggest a 500 kcal lunch."}"#.utf8), contentType: "application/json" ) { try await URLSession.shared.data(for: $0) } ``` `authorizedRequest` takes the same arguments without the closure and returns the request alone, for an app that wants to drive the retry itself; see **Recovery** under [What the client does for you](#what-the-client-does-for-you). ## Show the user where they stand `withCurrentUserRequest` sends a `GET /v1/apps/{app}/me`, which answers the limits that apply to the caller, its requests today, the month's spend against its budget, and whether it is blocked. On an iOS app the request counts are the install's and `install_id` names it. It retries a `401` the same way; `currentUserRequest()` builds the request alone. ```swift let (data, _) = try await gateway.withCurrentUserRequest { try await URLSession.shared.data(for: $0) } ``` Build it with the client rather than from a URL of your own: when your app verifies App Attest on every request, it must be signed like any other gateway request, so one without a proof is refused with `401 assertion_required`, and changing a signed request's URL afterwards breaks its proof. The response is described under [Calling the provider proxy](https://docs.appaigateway.com/integrate/proxy#reading-your-own-limits). ## Per-request proof Each iOS app's **Verify App Attest on** setting, `verify_on`, decides whether requests are signed: - **Every request** (`every_request`, the default for new apps): every request the app sends through the gateway is signed. Alongside the gateway token it carries an `X-AppAttest-Proof` header: an assertion the install's App Attest key made over that one request. A gateway token copied off the network is worthless without the device's key, and a proof is accepted once. Needs the package 1.4 or later. - **Token exchange** (`token_exchange`): the assertion is checked once per hourly token, and requests carry only the token. The token exchange answers with the app's `verify_on`, and the package signs requests only when it is `every_request`; a gateway that does not send it is treated as `token_exchange`. Build every request through the package either way, and switching the setting needs no change in your app. Apps created before this setting was added were set to **Token exchange**, so builds already in your users' hands, on a package before 1.4, keep working. Switch to **Every request** on the app's **Auth policy** page once every build in use is on 1.4 or later: from then on a request without a proof is refused with `401 assertion_required`. A 1.4 client still holding a token from before the switch is refused that way too, until it discards the token and rebuilds the request, which `withAuthorizedRequest` does on its own; see **Recovery** under [What the client does for you](#what-the-client-does-for-you). With **Every request**, the client data the key signs is the SHA-256 of these lines joined by a newline, with no trailing newline: ```text appaigateway-request-v1 <16 random bytes, base64url> ``` and the header is `v1...`. The timestamp is signed, but the gateway does not compare it with its own clock; the assertion counter is what stops a proof being used twice. The package computes all of it. What that asks of your code: - **Set the body before it is signed.** Pass it as `body:`. Changing `httpBody` or the URL of the request it built invalidates the proof, and the request is refused with `403 attest_failed`. Other headers may be added. - **Keep provider paths plain ASCII.** The proof covers the path and query as the gateway reads them, which is how a standard (WHATWG) URL parser reads the URL that arrives. The package rewrites the URL it returns into that form before signing it: `.` and `..` segments resolved, and spaces, quotes, `'` in a query, `^` in a path and any non-ASCII character percent-encoded, with escapes already present left as they are. A plain ASCII path never needs rewriting, so the URL you passed in is the one sent. - **One request per proof.** Build a new request for every send, retries included; `withAuthorizedRequest` does so for the one it makes. Replaying a request the gateway has already accepted is refused as a replayed assertion. - **Version 1.4 or later.** Earlier versions of the package send no proof, so an app that verifies every request refuses their requests with `401 assertion_required`. Moving to 1.4 means passing the body to `authorizedRequest` instead of assigning it afterwards; nothing else in your code changes. An app whose API-key mode builds server requests sends no proof: API keys prove themselves. ## What the client does for you - **App Attest registration.** On first use it generates a key in the Secure Enclave, asks the gateway for a challenge, attests the key with Apple, and registers it. The key ID is stored in the Keychain under the service `dev.aigateway.credentials`, so later launches skip this step. - **Token exchange.** It obtains a gateway token valid for one hour by signing a fresh challenge with the key, with the ID token alongside when the mode has one. Concurrent callers share one exchange. - **Refresh.** It refreshes the token five minutes before expiry in a background task, so a request rarely waits for an exchange. - **Request signing.** When the app verifies App Attest on every request, it signs every request it builds, `/me` included, with the key the gateway token is bound to; see [Per-request proof](#per-request-proof). - **Recovery.** After a reinstall the Keychain may still name a key that no longer exists in the Secure Enclave. The client detects the local signing failure, registers a new key, exchanges it for a token bound to the new key and signs once more, whether that happens at the token exchange or while signing a request. It also replaces the key once when the gateway answers `attest_failed` at the token exchange. When a request sent through `withAuthorizedRequest` is refused with `401`, `auth_required` or `assertion_required`, it discards the token and builds and sends the request once more. The next exchange answers the app's current `verify_on`. `assertion_required` on a request built by the package and sent with its headers intact means the app was switched to **Every request** after its cached token was issued under **Token exchange**; the rebuilt request is signed. A request assembled from `gatewayAccessToken()` by hand gets it whenever the app is on **Every request**. If you send requests built with `authorizedRequest` instead, a `401` is yours to handle: call `discardAccessToken()` and build the request again, once. A `403 attest_failed` on a request is returned to you, never retried: a key the gateway does not know, an environment it no longer accepts, a malformed proof and a request changed after signing all answer it. Calling `discardAccessToken()` and building the request again helps when the key was lost, because the fresh exchange re-registers it, and not when the environment is no longer accepted or the request was changed after signing. - **Rate limits.** If the gateway refuses `challenge`, `register` or `token` with a `429`, the client remembers how long the wait is and refuses the next call itself until it passes, rather than asking Apple for a new key on every attempt. The error it raises carries the time still left in `retryAfter`. A token it already holds keeps working throughout. - **ID token retries.** On `issuer_token_rejected` it asks your provider for a refreshed token once and retries. On `issuer_claims_missing` it first runs your recovery hook, described below, then retries with a refreshed token. - **App version.** `X-App-Version` is set from `CFBundleShortVersionString`, which is what the console's **By app version** breakdown groups by. ## Handling a missing entitlement With a [subscription check](https://docs.appaigateway.com/applications/auth-policy/subscription-check), a user who has just paid may hold a token that does not carry the entitlement yet. The gateway answers `issuer_claims_missing`, and the client runs the `issuerRejectionRecovery` closure before retrying. Use it to sync the purchase, for example by asking RevenueCat to refresh, so the refreshed token carries the claim: ```swift let gateway = AppAIGatewayClient( appID: "example-app-a1b2c3", baseURL: URL(string: "https://api.appaigateway.com")!, authMode: .appAttest(issuerTokenProvider: { forceRefresh in try await Auth.auth().currentUser!.getIDToken(forcingRefresh: forceRefresh) }), issuerRejectionRecovery: { _ = try await Purchases.shared.syncPurchases() } ) ``` The hook runs only for `issuer_claims_missing`, never for `issuer_token_rejected`, so an expired token does not trigger a store round trip. ## Reading errors Every refusal is a JSON body with a code, and `GatewayError(response:body:)` reads it. The useful properties: - `code`: a `GatewayErrorCode` such as `.appRateLimited`, or `.unknown` for a code this version of the package does not know. - `retryAfter`: seconds until a rate limit or budget window reopens. - `limitScope`: `"install"`, `"user"` or `"app"` for an app limit, so you can tell "slow this caller down" from "the whole app is busy". An iOS app's request limits count per install, one copy of the app on one device. - `isRetryable`: whether the same request could succeed later without a change. `.rateLimited` is the gateway protecting its own authentication endpoints, so it carries no `limitScope` — it is not one of the limits you set on your app. The client has already started waiting that window out, so show "try again in `retryAfter` seconds" and leave the user signed in. The full code list and what each one means for your app is on [Errors and limits](https://docs.appaigateway.com/integrate/errors). ## Server-side use of the package The package also builds requests for server applications, with `.apiKey(key:)` for an app with no user identity or `.apiKey(key:issuerTokenProvider:)` for one with signed-in users, and `endUserId` for an app whose backend names the user. That is useful for a macOS or server-side Swift process. Never use `.apiKey` inside an iOS app: an API key shipped in an app can be extracted. --- # Server applications Source: https://docs.appaigateway.com/integrate/server/ Call the gateway from a backend with an API key, point standard SDKs at it, and attribute requests to your users. A server application authenticates with an API key. The key is created when the app is created and later on the app's **Auth policy** page, and it is shown once. Keep it in your backend's secret manager and read it from the environment. ## Base URL Every request goes to: ```text https://api.appaigateway.com/v1/apps/{app}/proxy/{provider}/{provider_path} ``` `{app}` is the app ID from the console, `{provider}` is a provider slug such as `openai` or `anthropic`, and `{provider_path}` is the provider's own API path. The app's **Settings** page shows this URL filled in for your app. > **Self-hosting** > > Replace `https://api.appaigateway.com` with your gateway's API origin. Keep > the `/v1/apps/...` path and use the credentials from that deployment. ## Without user identity Send the key as the bearer credential on every request: ```sh curl https://api.appaigateway.com/v1/apps/search-service-k3f9x1/proxy/openai/v1/responses \ -H "Authorization: Bearer $APP_AI_GATEWAY_KEY" \ -H 'Content-Type: application/json' \ -d '{"model":"gpt-5.6-sol","input":"Say hello."}' ``` ### Standard SDKs Set the SDK's base URL to the gateway, up to and including the provider's version prefix, and give it the gateway key where it expects the provider key. The SDK appends its own paths. ```python import os from openai import OpenAI client = OpenAI( api_key=os.environ["APP_AI_GATEWAY_KEY"], base_url="https://api.appaigateway.com/v1/apps/search-service-k3f9x1/proxy/openai/v1", ) response = client.responses.create(model="gpt-5.6-sol", input="Say hello.") ``` ```typescript import Anthropic from "@anthropic-ai/sdk"; const client = new Anthropic({ apiKey: process.env.APP_AI_GATEWAY_KEY, baseURL: "https://api.appaigateway.com/v1/apps/search-service-k3f9x1/proxy/anthropic", }); const message = await client.messages.create({ model: "claude-sonnet-5", max_tokens: 256, messages: [{ role: "user", content: "Say hello." }], }); ``` The gateway accepts the credential in the `Authorization` header and also in the provider's own key header (`x-api-key` for Anthropic, `x-goog-api-key` for Gemini), which is why the SDKs work unchanged. Any provider key the SDK would have sent is replaced with yours on the gateway. ## Your backend sends the user id If the app's **User authentication** is **Your backend sends the user id**, add the configured header, `x-end-user-id` by default, with your own user identifier on every request: ```http Authorization: Bearer agw_… X-End-User-ID: customer-123 ``` The header is required once configured, so a request without it answers `400 invalid_request`. The gateway strips it before the request reaches the provider. Per-user limits and usage then apply to that identifier. Block these users in your backend; the gateway does not offer blocking for header-based identities. With an SDK, set it as a default header. ## Signed-in users If the app's **User authentication** is **Signed-in users only**, the key alone is no longer accepted on requests. Exchange it together with the user's sign-in JWT for a gateway token, then send the gateway token with `X-App-Version`: ```sh curl https://api.appaigateway.com/v1/apps/search-service-k3f9x1/auth/token \ -H 'Content-Type: application/json' \ -d "{\"api_key\":\"$APP_AI_GATEWAY_KEY\",\"issuer_token\":\"$ID_TOKEN\"}" # {"access_token":"…","expires_in":3600} curl https://api.appaigateway.com/v1/apps/search-service-k3f9x1/proxy/openai/v1/responses \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'X-App-Version: 2.3.0' \ -H 'Content-Type: application/json' \ -d '{"model":"gpt-5.6-sol","input":"Say hello."}' ``` The gateway token is valid for an hour and stands for one user. Cache it per user and exchange again before it expires. A refused exchange answers with one of the three codes described under [User authentication](https://docs.appaigateway.com/applications/auth-policy/user-authentication#what-the-app-sends). ## Rotating a key Create a new key on the **Auth policy** page, move your backend to it, then revoke the old one. Revocation takes effect within a minute. ## What to watch - `429 app_rate_limited` and `429 app_budget_exhausted` carry `Retry-After` and `data.scope`. An `app` scope means the whole app's limit is spent and backing off one user does not help. - `502 provider_not_configured` means the slug in your URL has no provider. - `400 pricing_not_configured` means the model has no price yet. Add one under the provider's **Pricing** action. The full list is on [Errors and limits](https://docs.appaigateway.com/integrate/errors). --- # Calling the provider proxy Source: https://docs.appaigateway.com/integrate/proxy/ The URL shape, the credential headers, what is forwarded to the provider, and how streaming works. The proxy route forwards a provider-native request to one of your providers: ```text {METHOD} https://api.appaigateway.com/v1/apps/{app}/proxy/{provider}/{provider_path} ``` - `{app}` is the app ID. - `{provider}` is a provider slug: `openai`, `anthropic`, `gemini`, `xai`, or a custom slug such as `openai-dev`. It names one of your provider keys, not a provider type. - `{provider_path}` is the provider's own API path, verbatim and without a leading slash. New apps allow inference endpoints, including each provider's image, embedding and speech endpoints. Other operations need an explicit [allowed path](https://docs.appaigateway.com/applications/provider-access), and a fixed model for pricing when the request does not name one. The provider route must support the operation too. The body is the provider's body and the response is the provider's response. The provider's documentation is the reference for both. ## Credentials | App | Header | | --- | --- | | iOS app | `Authorization: Bearer ` and `X-App-Version: `, plus `X-AppAttest-Proof: ` when the app verifies App Attest on every request | | Server app with signed-in users | `Authorization: Bearer ` and `X-App-Version: ` | | Server app without signed-in users | `Authorization: Bearer agw_…` | `X-App-Version` is required whenever the credential is a gateway token. It is recorded on every request and is how the console's **By app version** breakdown tells one release from another. The Swift package sets it from the bundle's version. When an iOS app's **Verify App Attest on** is **Every request**, the default for new apps, every request with its gateway token must carry `X-AppAttest-Proof`, an App Attest assertion signed over that request. It covers the method, the path and query, the body and the token, so build the request completely, body included, before it is signed, and send it unchanged. A missing proof answers `401 assertion_required`; one that does not match the request answers `403 attest_failed`. With **Token exchange** the gateway token alone is enough, and a proof sent anyway is ignored. The header is consumed by the gateway and never reaches the provider, and a server app's requests do not need it. The Swift package computes it: see [Per-request proof](https://docs.appaigateway.com/integrate/ios#per-request-proof). The credential may also be sent in the provider's own key header, such as `x-api-key` for Anthropic or `x-goog-api-key` for Gemini. That is what lets a provider SDK work with the gateway token in place of the provider key. The `Bearer` prefix is optional. ## Paths by provider | Provider | Typical path | | --- | --- | | OpenAI | `v1/responses`, `v1/chat/completions`, `v1/audio/transcriptions` | | Anthropic | `v1/messages` | | Gemini | `v1beta/models/{model}:generateContent` or `v1beta/openai/chat/completions` | | xAI | `v1/responses`, `v1/chat/completions`, `v1/stt` | | Perplexity, DeepSeek, ByteDance | `chat/completions` | | Groq | `openai/v1/chat/completions` | | Fireworks AI | `inference/v1/chat/completions` | | Mistral, Together AI, Cerebras, Moonshot AI, Hugging Face, Baseten, OpenRouter | `v1/chat/completions` | A provider routed through Vercel AI Gateway accepts only `v1/responses`, `v1/chat/completions` and `v1/messages`. The full table with each provider's caveats is on [Supported providers](https://docs.appaigateway.com/providers/supported-providers). ### Transcription response formats A transcription is billed by the tokens or the audio duration its answer reports, and a plain-text answer reports neither. So the gateway refuses a `response_format` of `text`, `srt` or `vtt` with `400 invalid_request` on OpenAI, Groq and Together AI. On OpenAI, use the default `json`, or `verbose_json` or `diarized_json` where the model supports them. Groq and Together AI report the duration only in `verbose_json`, so the gateway sends their transcriptions in that format when the request names `json` or no format at all. The answer still carries the same `text` field, plus timing details. On these providers a transcription must be a `multipart/form-data` upload that names `response_format` at most once. On every provider, an upload that names `model` more than once is refused. Both are refused with `400 invalid_request`. For an SDK, the base URL is the proxy URL up to the provider's version prefix, for example `…/proxy/openai/v1`; the SDK appends `responses` or `chat/completions` itself. ## Model names Model names are the provider's own on every route: `gemini-2.5-flash`, never `google/gemini-2.5-flash`. If the app has a model rewrite, send the name the rewrite expects. If it has an allowlist, only listed names are accepted. ## Streaming Set the provider's own streaming option, `"stream": true` for most providers or the `:streamGenerateContent` path for Gemini, and read the stream as the provider documents it. The gateway forwards it unbuffered and reads usage out of it as it passes. If your client disconnects mid-stream, the provider call is cancelled. ## What reaches the provider The gateway adds your provider key and forwards a fixed set of request headers: - `content-type`, `accept`, `accept-language`, `user-agent` - `anthropic-version`, `anthropic-beta`, `anthropic-dangerous-direct-browser-access`, `openai-beta`, `x-goog-api-client`, `idempotency-key` - anything starting with `x-stainless-`, which the OpenAI and Anthropic SDKs send Everything else is dropped, so a provider never sees your user's address, cookies or a header you did not intend to send. In particular `openai-organization`, `openai-project` and `x-goog-user-project` are dropped because they would redirect spend to another account, and any provider key your client sends is replaced. Through a Cloudflare AI Gateway, the documented `cf-aig-cache-ttl`, `cf-aig-skip-cache`, `cf-aig-max-attempts`, `cf-aig-backoff` and `cf-aig-retry-delay` controls are forwarded too. On the way back, `set-cookie`, `openai-organization` and `openai-project` are removed. `x-request-id` is kept, because provider support asks for it. The gateway adds a `Server-Timing` header with the time spent on authentication, limits and the provider's time to first byte. ## Limits on a request - Request bodies are limited to 20 MB. Larger ones answer `413 payload_too_large`. - If the provider sends no response headers within 120 seconds, the request answers `504 provider_error`. After headers arrive there is no gateway body deadline. Self-hosted gateways can configure this timeout. - If the app sets **Max output tokens** for the provider, a request asking for more is refused with `403 max_output_tokens_exceeded`, and one that asks for nothing gets the cap injected. ## Provider errors A provider's own error response, such as a `400` for a malformed body or a `429` from the provider's rate limit, is passed through with the provider's status and body, and recorded as a `provider_error` event. A request the gateway could not deliver at all answers `502 provider_error` in the gateway's own format. ## Reading your own limits An app that identifies users can call `GET /v1/apps/{app}/me` with the same credentials as a proxy request: ```json { "user_id": "firebase-uid", "install_id": "c2lnbmVkLWtleS1pZA==", "limits": { "requests_today": 12, "requests_remaining": 9988, "requests_per_minute": 100, "requests_per_day": 10000, "monthly_cost_usd": 0.41, "monthly_budget_usd": null, "blocked": false } } ``` `install_id` is the App Attest key id of the calling install on an iOS app, whose request counts and, by default, spend are per install; it is `null` on a server app. `requests_today` is `null` when the app sets no per-user limit, because requests are then not counted. An app with no user identity answers `404 auth_method_not_supported`. --- # Calling custom endpoints Source: https://docs.appaigateway.com/integrate/endpoints/ Send a request body to a slug and let the gateway choose the provider, model and parameters. A [custom endpoint](https://docs.appaigateway.com/applications/custom-endpoints) is a URL whose provider and model are configured on the gateway: ```text POST https://api.appaigateway.com/v1/apps/{app}/endpoints/{slug} ``` Authentication is identical to the proxy: a gateway token with `X-App-Version`, or an API key. Limits, usage and error codes are the same. Only `POST` is accepted. ## Request bodies Send the body of the endpoint's request format, as the provider documents it. You may omit `model`; if you send one, the gateway replaces it with the endpoint's model, and for the Gemini formats it puts the model in the URL instead. The endpoint's parameters are deep-merged over your body, with the endpoint winning on conflicts, and a text endpoint's output cap is applied like the proxy's. | Request format | Body | | --- | --- | | OpenAI Responses | `{"input": "…"}` | | OpenAI Chat Completions | `{"messages": [{"role": "user", "content": "…"}]}` | | Anthropic Messages | `{"max_tokens": 1024, "messages": [{"role": "user", "content": "…"}]}` | | Gemini generateContent | `{"contents": [{"parts": [{"text": "…"}]}]}` | | OpenAI image generation | `{"prompt": "…"}` | | OpenAI embeddings | `{"input": "…"}` | | Gemini embedContent | `{"content": {"parts": [{"text": "…"}]}}` | | OpenAI audio transcription | A `multipart/form-data` upload with a `file` field | ```sh curl https://api.appaigateway.com/v1/apps/example-app-a1b2c3/endpoints/chat \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'X-App-Version: 1.4.2' \ -H 'Content-Type: application/json' \ -d '{"input":"Suggest a 500 kcal lunch."}' ``` The response is in the serving provider's format. If a Responses endpoint falls back from OpenAI to xAI, you get xAI's Responses-style body. Both follow the same shape, but read the usage and any provider-specific fields defensively. An Anthropic Messages endpoint sends `anthropic-version: 2023-06-01` when your request has no `anthropic-version` header, and yours when it does. A Gemini speech model answers with audio only when the request asks for it with `"generationConfig": {"responseModalities": ["AUDIO"]}`. Send it from your clients or set it in the endpoint's parameters. ## Streaming Streaming works as on the proxy: set `"stream": true` and read the event stream. On a Gemini generateContent endpoint, add `?alt=sse` to the endpoint URL instead, as Gemini's own API does; the gateway calls the streaming method for you. A fallback happens only if nothing has been streamed yet. From Swift, pass the query to the request helper rather than adding it to the URL afterwards: an App Attest proof is signed over the URL, query included. This needs the Swift package 1.6.0 or later. ```swift let (bytes, response) = try await gateway.withAuthorizedRequest( endpointSlug: "gemini-chat", query: [URLQueryItem(name: "alt", value: "sse")], body: body, contentType: "application/json" ) { try await URLSession.shared.bytes(for: $0) } ``` ## Transcription endpoints Send an OpenAI audio-transcription multipart body. `model` may be omitted. Leave `response_format` at its default or choose another JSON format: a plain-text answer (`text`, `srt` or `vtt`) reports nothing to bill, so it is refused with `400 invalid_request`. ```sh curl https://api.appaigateway.com/v1/apps/example-app-a1b2c3/endpoints/transcribe \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'X-App-Version: 1.4.2' \ -F file=@note.m4a ``` ## With the Swift package ```swift let (data, response) = try await gateway.withAuthorizedRequest( endpointSlug: "chat", body: Data(#"{"input":"Suggest a 500 kcal lunch."}"#.utf8), contentType: "application/json" ) { try await URLSession.shared.data(for: $0) } ``` Pass the body to `withAuthorizedRequest`: when the app verifies App Attest on every request, the request is signed over it, as described in [Per-request proof](https://docs.appaigateway.com/integrate/ios#per-request-proof). ## Errors An unknown slug answers `404 endpoint_not_found`. A method other than `POST` answers `404 invalid_request`. When every target in the chain fails, the last provider's error is returned, or `502 provider_error` if none could be reached. --- # Errors and limits Source: https://docs.appaigateway.com/integrate/errors/ Common client errors, their response format, and how to handle limits and failures. Every refusal from the gateway is JSON with one `error` object: ```json { "error": { "code": "app_rate_limited", "message": "Per-user rate limit exceeded", "data": { "scope": "user" } } } ``` `code` is stable and is what your client should switch on. `message` is for logs. `data` is present only when the code alone is not actionable. Every `429` carries a `Retry-After` header in seconds. A provider's own error is not in this format: it is passed through as the provider sent it, with the provider's status code. Provider errors may also contain `error.code`, so that field alone does not prove where an error originated. Handle known gateway codes, retain the HTTP status and provider request ID when present, and inspect **Usage** and **Auth & Errors** to diagnose the source. ## Authentication | Code | Status | Meaning | What to do | | --- | --- | --- | --- | | `auth_required` | 401 or 403 | No usable credential, an expired gateway token, a revoked key, or a blocked user | Refresh an expired gateway token once. On iOS, `withAuthorizedRequest` in the Swift package 1.4.1 or later does this for you on a `401` and returns a `403` as it is; if you send a request built with `authorizedRequest`, call `discardAccessToken()` and build it again, once. If it persists, check the app credential and the user status in the console | | `assertion_required` | 401 | A request with an iOS app's gateway token arrived without the `X-AppAttest-Proof` header, on an app whose **Verify App Attest on** is **Every request**. Only that setting answers this code | On the Swift package 1.4 or later, a request built by the package sees this only when the app was switched to **Every request** after the client's token was issued. `withAuthorizedRequest`, in 1.4.1 or later, handles it: it discards the token and sends a rebuilt, signed request once. If you send a request built with `authorizedRequest`, call `discardAccessToken()` and build the request again, once. A request assembled by hand from `gatewayAccessToken()` must be built with the package instead. On an earlier package, update to 1.4 or later, or set **Verify App Attest on** to **Token exchange** while older builds are still in use | | `attest_failed` | 403 | An App Attest proof was refused. At the token exchange: the attestation or assertion did not verify. On a request: the `X-AppAttest-Proof` header is malformed, signed by a key that is not registered or whose environment is no longer accepted, signed over a different request, or its assertion counter was already used. The message says which | `withAuthorizedRequest` in the Swift package returns it to you rather than retrying it. Build a new request and send it once. If it persists, call `discardAccessToken()` and build the request again: the fresh exchange re-registers the key when the gateway no longer knows it. That does not help when the key's environment is no longer accepted or the request was changed after signing | | `issuer_token_rejected` | 403 | The sign-in token did not verify | Refresh the ID token, retry once, then treat as signed out | | `issuer_claims_missing` | 403 | The token verified but the required entitlement claim is not there yet | Wait and retry. Do not sign the user in again. Sync the purchase if you can | | `issuer_verification_unavailable` | 503 | The identity provider's keys could not be fetched | Retry with backoff | | `auth_method_not_supported` | 404 | The app is not configured for this call, such as a token exchange on an app without user identity | Fix the app's configuration | | `invalid_request` | 400 | A malformed body, a missing `X-App-Version`, or a missing end-user header | Fix the request | | `rate_limited` | 429 | Too many calls to `challenge`, `register` or `token` for this app from one network address. `data.scope` names the endpoint: `app_auth_challenge`, `app_auth_register` or `app_auth_token` | Wait `Retry-After` seconds. A real app almost never reaches this, so look for a retry loop of your own, or a lot of devices sharing one address | `rate_limited` is the gateway protecting these three endpoints itself, and is counted per app and per address. It is not one of the limits you set on your app below, and nothing in the console changes it. ## Limits | Code | Status | Meaning | What to do | | --- | --- | --- | --- | | `app_rate_limited` | 429 | A per-minute or per-day request limit set on this app refused the call. `data.scope` is `install`, `user` or `app` | Wait `Retry-After` seconds. An `install` or `user` scope means slow this caller down; an `app` scope means the whole app is busy | | `app_budget_exhausted` | 429 | A monthly spending budget set on this app is spent. Same `data.scope` | Wait for the next month, or raise the budget in the console | | `billing_request_quota_exceeded` | 429 | The monthly request allowance of the account that owns the app is spent. `data` carries `limit`, `used` and `resetAt` | Nothing on the device fixes it. Show a friendly message and retry after `resetAt` | | `billing_unavailable` | 503 | The gateway could not check the account, so it refused rather than guess | Retry after `Retry-After`. Not an account problem | | `billing_payment_required` | 402 | Account access needs attention in the console | Ask the person managing the gateway account to resolve it; retrying on the device will not help | | `payload_too_large` | 413 | The request body is over 20 MB | Send less | `app_*` codes come from limits set on the app in the console. `billing_*` codes come from the account and are unrelated to those limits. ## Account access These refusals concern the gateway account, not your app's end-user sign-in: | Code | Status | What to do | | --- | --- | --- | | `unclaimed_access_expired` | 403 | The person who started the account must claim it through the CLI or MCP browser handoff. Do not ask the app's end user to sign in again. | | `account_expired` | 403 | The account's recovery deadline has passed. Stop automatic retries and ask the person managing the gateway account to investigate. | | `email_unverified` | 403 | The gateway account's owner has not verified their email address yet. They open the link in the verification email, or send a new one from the console; requests are served from then on. Do not ask the app's end user to sign in again. | ## Policy | Code | Status | Meaning | What to do | | --- | --- | --- | --- | | `path_not_allowed` | 403 | The app's proxy policy does not allow this provider or path | Use an allowed path, or allow it in the console | | `model_not_allowed` | 403 | The model is not in the app's allowlist | Use an allowed model | | `max_output_tokens_exceeded` | 403 | The request asks for more output than the app allows | Lower the output limit in the request | | `api_style_not_supported` | 403 | This path does not exist on the route the provider uses, such as Gemini's native API through Vercel | Use a supported path or a direct provider | | `pricing_not_configured` | 400 | The model has no price in the catalog or the provider's custom pricing | Add a price in the console, or use a catalogued model | | `endpoint_not_found` | 404 | No custom endpoint has this slug | Check the slug | | `app_not_found` | 404 | No app has this ID | Check the app ID | | `app_disabled` | 403 | The app is switched off in its settings | Nothing on the device fixes it | ## Providers | Code | Status | Meaning | What to do | | --- | --- | --- | --- | | `provider_not_configured` | 502 | No provider has this slug | Add one in the console | | `provider_disabled` | 502 | The provider with this slug is paused | Enable it in the console | | `provider_unavailable` | 502 | The provider's key could not be read | Check the gateway's health | | `provider_error` | 502 or 504 | The provider could not be reached, or sent no response headers before the timeout (120 seconds by default) | Retry with backoff | | `service_unavailable` | 503 | The infrastructure the gateway runs on failed or was overloaded, or a sign-in or `/me` call took longer than 10 seconds. Nothing about the request is known to be wrong | Retry after `Retry-After` with backoff | | `internal_error` | 500 | Something failed inside the gateway | Retry with backoff | ## A sensible client policy - Use bounded backoff for transient `429` and `5xx` errors, respecting `Retry-After`. A budget or account refusal needs the action in its row above. Avoid automatically replaying inference after a partial response or a lost connection: the provider may already have processed and charged for it. - On `app_rate_limited` with scope `install` or `user`, back that caller off for `Retry-After` seconds. An iOS app counts request limits per install. With scope `app`, show a "busy, try again shortly" state rather than hammering. - On `issuer_claims_missing`, keep the user signed in and poll gently. - Send `X-App-Version` on every request, so the console can tell you which release a problem belongs to. - Log `code` and `message`, never the request body or the token. The Swift package's `GatewayError` exposes `code`, `retryAfter`, `limitScope` and `isRetryable` for exactly this. See [iOS with the Swift package](https://docs.appaigateway.com/integrate/ios#reading-errors). --- # Use with AI agents Source: https://docs.appaigateway.com/automation/agents/ Connect an agent to your account, let it configure apps, and supply credentials privately. An agent can add providers, configure your apps, write integration code and investigate usage. You supply credentials in your own browser, and the agent works with resource IDs and configuration. ## Choose how to connect | Your agent can use | Start here | | --- | --- | | A terminal in your project | Install the [CLI](https://docs.appaigateway.com/automation/cli) and use the prompt below. It can save a server app's key to a private file. | | A remote MCP server | Add `https://console.appaigateway.com/mcp` to your client's MCP settings and approve its OAuth connection. See [MCP server](https://docs.appaigateway.com/automation/mcp). Application keys are revealed to you in your browser. | | Only HTTP requests | Use the [management API](https://docs.appaigateway.com/automation/api) with a credential supplied through a secret store. | For a review or usage investigation, choose **Read only** on the MCP consent page or when creating a management key on the console's **Access** page. Choose **Manage** when the agent needs to configure resources. > **Self-hosting** > > Use your console's address followed by `/mcp`, or connect the CLI with > `agw deployment connect --url https://YOUR-GATEWAY --no-open --json`. > The same agent workflow applies. Deployment commands are needed only if you > want the agent to [create or maintain your own gateway](https://docs.appaigateway.com/self-hosting/deploy-to-cloudflare). ## Give the agent a task Install the [CLI](https://docs.appaigateway.com/automation/cli), then give your coding agent this prompt. Replace the last line with your app's details or the change you want: ```text Read https://docs.appaigateway.com/agents.md. Use agw for this task. Inspect the active deployment and account first. Use my existing hosted account; if login is needed, give me its URL and pairing code. Do not create another account. Read existing providers and apps, use --json --no-input, and validate changes. Give me browser handoff URLs for provider credentials; I will open them in my own browser. Never print secrets. Keep application keys in private files. Finish with app check and app snippet. Do not send inference unless I ask. Report the app ID, API base URL, changes, checks and any remaining setup. Task: Set up an iOS app for Team ID ABCDE12345 and Bundle ID com.example.app. ``` For MCP, use the same task and ask the agent to read `agw://guide`, start with `get_account`, and finish with `check_app` and `get_app_snippet`. Useful follow-up tasks include: - “Review this app's provider access and limits, and suggest changes.” - “Add a custom endpoint so I can change models without releasing my app.” - “Explain this app's recent failures using its auth events and refusal samples.” ## Use the right account **Already signed up:** sign the agent into that account first. Creating a provider while signed out can create a separate account; it does not find your existing one automatically. `agw account login --no-open --json` returns a pending login with a `url`, `userCode` and `id`. Open the URL in your own browser, check that the page shows the same pairing code, and approve. The agent then runs `agw operation wait --json` to finish. It receives a management key of its own without seeing your password or browser session. **Starting a new account with an agent:** explicitly tell it that you want a new account. The first provider or app creation on a fresh hosted CLI connection initializes one. Help, status, lists and validation do not. With MCP, choose **Continue without an account** on the consent page when offered. These unclaimed accounts are temporary; have the agent check the account's expiry and start a claim so you can keep it. `agw account claim --no-open --json`, or MCP's `claim_account`, returns a URL. Open it in your own browser, check the account, and create your sign-in there. If you are already signed in as someone with another account, the page asks you to sign out first. Claiming keeps the agent connected. ## Supply provider credentials For a missing provider, the CLI can start a browser handoff: ```sh agw provider add --type openai --browser --no-open --json --no-input ``` Open the returned URL in your own browser, check the provider and destination, and enter the credential. The agent waits with `agw operation wait --json` and receives only the non-secret result. MCP's `add_provider` uses the same kind of handoff, followed by `get_operation`. Do not paste provider keys into the conversation. A hidden terminal prompt prevents echo but does not isolate a secret from an agent controlling that terminal. Application keys created by the CLI go to private files; keys created through MCP are shown on a page you open yourself. For unattended CI, supply a management key as `AGW_MANAGEMENT_KEY` through the job's secret store, or save it once with `agw account login --key-stdin`. The environment credential overrides any saved login. Never paste it into a command argument or chat message. ## Check the result and end access The agent should report the resources it changed, their IDs and slugs, your app's API base URL, where any key was saved, and outstanding integration work. `app check` and `check_app` inspect configuration without making a provider request. They do not test a provider key, device attestation, user sign-in or subscription entitlement. A successful check is not an end-to-end test. Keep the CLI's private state after an interrupted request so it can recover pending operations. A successful command that returns `state: "pending"` is waiting for you; it has not finished the requested change. The console's **Access** page lists CLI keys and MCP connections. Revoke the agent's entry there when you want its access to end. `agw account logout` also revokes the CLI's saved key. Claiming an account does not revoke it. ## Machine-readable instructions - [Agent manual](https://docs.appaigateway.com/agents.md): the task workflow and configuration reference. - [Documentation index](https://docs.appaigateway.com/llms.txt): find the guide relevant to the task. - [Full documentation](https://docs.appaigateway.com/llms-full.txt): all handwritten guides in one file. - [OpenAPI contract](https://docs.appaigateway.com/openapi.json): request and response schemas. - Individual guides as markdown: replace the trailing slash with `.md`, for example `/integrate/ios.md`. The home page is `/index.md`. These are static files. Reading a guide does not install a skill or change your project instructions. You may link the manual from your own `AGENTS.md` if useful. --- # CLI Source: https://docs.appaigateway.com/automation/cli/ Install the agw CLI to sign in, add providers, create and check apps, and read usage from a terminal or a coding agent. Install Node.js 22.19 or later, then install the CLI: ```sh npm install -g @maxceem/agw agw --help agw deployment status --json ``` A fresh CLI uses the hosted API. If you already have an account, run `agw account login` before creating resources; otherwise a creation can start a separate account. A saved connection remains active across commands, so check the URL from `deployment status` and the account from `agw account status`. Use `agw deployment connect --cloud` to sign in to the hosted service again. It starts browser login unless you supply a management key. If you want a new hosted account, your first provider, provider-gateway or app creation initializes one and saves its credential privately. For example, run `agw provider add --type openai --browser --no-open`. Help, status, lists, validation and dry runs never create accounts. New unclaimed accounts are temporary; check `agw account status` and use `agw account claim` to keep yours. > **Self-hosting** > > Connect to an existing gateway with `agw deployment connect --url URL`. > To create one on your Cloudflare account, use `agw deployment setup --name > my-gateway --no-domain`. No source checkout is needed. See > [Deploy to Cloudflare](https://docs.appaigateway.com/self-hosting/deploy-to-cloudflare) for deployment and > offline archive options. ## Sign in `agw account login` signs the CLI in through your browser. It prints a pairing code, opens the console's approval page and waits: ```text Pairing code: ABCD-EFGH Check that your browser shows this same code before you approve. Approve at: https://console.appaigateway.com/cli/approve/… ``` Check that the page shows the same code before you approve: that is what tells you the request on the page is the one your terminal made. Sign in if you are not signed in already, pick the account if you belong to several, and approve. The browser comes back to the CLI, which receives a management key of its own, stores it privately and prints your account status. The tab can then be closed. Declining on the page ends the command with an error. The CLI waits up to five minutes, and the approval page stays valid for 15. To sign in to your own deployment, run `agw deployment connect --url https://ai.example.com`, which runs the same login against that URL. - **No browser on this machine**: over SSH, on Linux with no display, or when no one is at the terminal, the CLI does not try to open one. It prints the URL and the pairing code, as `--no-open` does anywhere, and the same happens if a browser fails to open. Open the URL on any device, or go to `/cli` on your console, for example `https://console.appaigateway.com/cli`, and type the code; then approve. The CLI collects its key once you have. - **An agent or a script**: with `--json` or `--no-input` the command returns at once with `state: "pending"`, the `url` and the `userCode`. Give the URL to a person, then run `agw operation wait ` to finish the login once they have approved it. Each key a login creates is listed on the console's **Access** page with a **CLI** tag and the label the CLI sent, such as `CLI on mac-studio`, so you can tell which machine holds which key. Revoke one there to end that machine's access. `agw account logout` revokes the saved key on the deployment and removes it from this machine. If the deployment cannot be reached, the key is still removed here, the command says it could not be revoked, and `revoked` is `false` in its `--json` result; revoke it on the **Access** page instead. ### Keys for agents and CI A machine that cannot approve anything in a browser uses an existing management key. Create one on the console's **Access** page, then either: - set `AGW_MANAGEMENT_KEY` in the environment. When it is set, it is the credential for every command and takes precedence over any saved login, and the environment key is not saved to disk. Commands can still save operation state and application-key files. `agw account login` refuses to run while it is set, and `agw account logout` only clears the saved login and reminds you the variable is still in charge. Use `agw deployment connect --url URL` to choose which deployment the key is sent to; it stores the URL, not the key. - or pipe it once with `--key-stdin`, which saves it like a browser login: ```sh agw account login --key-stdin < /private/path/management-key agw deployment connect --url https://ai.example.com --key-stdin < /private/path/management-key ``` `--key-prompt` asks for the key in a hidden prompt instead. Never pass a key as a command argument. ## Human handoffs Run `agw account claim` to establish human ownership. The CLI prints a browser URL. Open it in a browser outside the agent's visible session, compare the account shown with the terminal, and create your sign-in on that page. This account must be claimed by a new person, so if the browser is already signed in as someone with an account, the page asks you to sign out first. The page shows the account being claimed and the person approving, and one button completes it. The CLI stays connected afterwards, so an interrupted command can simply continue; to end that access later, run `agw account logout` or revoke its key on the console's **Access** page. Provider commands offer a browser handoff for write-only secrets. Share the URL with the person supplying the credential. The page shows the provider, destination and proposed changes. Secrets never appear in CLI output or later reads. Poll with the operation command shown by the CLI; handoffs last 15 minutes. ## Daily workflow ```sh agw account status --json agw provider list --json agw app list --json agw usage show --month 2026-09 --json agw app --help agw provider --help ``` Use the command's `--help` for its exact flags. Read an app before editing it. Application updates carry the revision read when the command starts and reject a competing write during that command. A `--file` exported earlier can still replace more recent settings: reread and merge before using it. Add and update with `--file` preserve the complete validated configuration, including native endpoint parameters. They do not transfer credentials or usage to another deployment. Build that file from `agw app show --json`, keeping `name`, `config` and `status` from `result.app`. `agw app snippet ` prints a request that app can send: curl for a server app, Swift for an iOS one. Creating an app prints it too. It is built from that app's own providers, allowed endpoints and priced models, and stands named placeholders in — `PROVIDER_SLUG`, `MODEL` — for anything not set up yet, so you always get something to run and can see what is still missing. For file changes, validate a new app with `agw app validate --file app.json --json`. For an existing app, use `agw app update --file app.json --dry-run --json`, which validates it as an update. Check the returned `validation.remote`: without a login, draft validation checks only the local schema and cannot verify provider references or pricing. After saving, run `agw app check --json`. It reads configuration without sending inference; it does not verify upstream keys, App Attest on a device, sign-in or entitlements. Generate the matching client example with `app snippet` and address any placeholder notes before using it. `agw app show` and `agw app check` print an app's warnings. Flag-based `agw app add --type ios` enables both production and development App Attest environments. Use `--attest-environments production` for a shipped app, and a separate development app when needed. The console and an API body that omits environments default to production only. `--verify-on every_request` or `--verify-on token_exchange`, on `agw app add --type ios` or `agw app update`, sets when the app verifies App Attest. A new app verifies every request, which needs clients on the AppAIGateway Swift package 1.4 or later; `token_exchange` checks the assertion once per hourly token. Apps created before this setting was added were set to `token_exchange`, so switch one to `every_request` only once every build in use is on 1.4 or later. Application keys are written only to a private file. Select a path with `--key-output`, or use the default managed private file. Use the browser login, `AGW_MANAGEMENT_KEY` or `--key-stdin` to authenticate; see [Sign in](#sign-in). Never put secrets in command arguments, source control, chat or logs. The CLI does not make inference requests as a setup check. Provider inference must be requested explicitly. ## Human support Where your deployment offers human support, `agw support send` emails it for you, and the reply comes to your account's email address: ```sh agw support send --type bug --message "Streaming responses stop after 30 seconds" --context app_id=my-app ``` `--type` is `bug`, `support` or `feedback`. Pipe a longer message in with `--message-stdin`, and add facts with repeated `--context name=value`. In a terminal the CLI shows the message and asks before sending; without one it sends only with `--yes`, so an agent has to have shown you the message first. A message that looks like it contains a key, token or password is refused, and the same message sent twice within ten minutes is sent once. The answer carries a reference such as `SUP-7F3K2Q` to quote when you follow up. ## Output and recovery `--json` writes one JSON document to stdout with `schemaVersion`, `ok` and either `result` or `error`. Progress goes to stderr. Without `--json` the same result is printed as plain text to read, and a failure is printed to stderr instead of stdout. A successful result with `state: "pending"` still needs its browser step and `operation wait`; it does not mean the change finished. Exit codes are 0 for success, 2 for input errors, 3 for remote failures, 4 for state or authentication recovery, and 5 for a wait timeout. A timeout leaves the operation pending. A request refused by a limit says which one: `error.message` names the limit, its ceiling and what the count is shared across, and `error.details` carries `scope`, `limit`, `windowSeconds`, `retryAfterSeconds` and `resetAt`. Wait `retryAfterSeconds` and repeat the same command. The CLI keeps one active connection. Its private state lives in `$XDG_STATE_HOME/agw` or `~/.local/state/agw`, and `%LOCALAPPDATA%/agw` on Windows. It saves initiation proofs before requests and credentials before reporting success. Repeat a failed command with the same state to recover a lost response; do not delete state or invent a new request proof. A recovered application key can finish writing to a new unused output file without issuing another key. A short lock guards each write to that state rather than the command around it, so other commands still run beside an `agw operation wait` that is polling. A deployment installed with `agw deployment setup` keeps its vault key in a file of its own under `vault-keys/` in the same directory, and keeps nothing else from the install. Back that directory up privately: losing the vault key makes stored provider credentials unreadable. Remote key replay lasts 15 minutes. After it expires, recovery identifies the existing resource; inspect it and replace its key intentionally. Each creation is saved as an operation before it is sent, so running the same command again after a lost response never creates a duplicate. Account usage includes retained records from deleted apps. `agw deployment update --dry-run` and `agw deployment domain --hostname ai.example.com --dry-run` inspect changes without deploying. Updating a mobile app's embedded gateway URL remains a separate application release. --- # MCP server Source: https://docs.appaigateway.com/automation/mcp/ Connect an MCP client such as a coding agent to your gateway and let it read and change your apps, providers and usage. The gateway is also a [Model Context Protocol](https://modelcontextprotocol.io) server. An MCP client — a coding agent, a desktop assistant, an IDE — can connect to it to read your apps, providers and usage and to change them, through tools written for an agent rather than for an HTTP client. Each tool runs the same operation the API and the [CLI](https://docs.appaigateway.com/automation/cli) run, with the same rules, so connecting a client over MCP gives it no more and no less than the credential it connects with. No key or token ever passes through a tool, in either direction. ## First task Add `https://console.appaigateway.com/mcp` to your client's remote MCP servers, then approve the OAuth connection in your browser. Select your existing account and **Read only** for inspection, or **Manage** for configuration. Use **Continue without an account** only when you want a new account. Ask the agent to read `agw://guide`, call `get_account`, and inspect your apps and providers before changing them. For example: “Check my app's configuration and show the integration snippet; do not send inference.” It can use `check_app` and `get_app_snippet` without making a provider request. > **Self-hosting** > > Use your console's origin followed by `/mcp`. OAuth requires the settings in > [Configuration](https://docs.appaigateway.com/self-hosting/configuration#mcp-clients); management keys are > an alternative when OAuth is not configured. ## Endpoint The server is one URL on the host you sign in to the console at: ```text https://console.appaigateway.com/mcp ``` On your own deployment it is your console's address followed by `/mcp`, such as `https://console.example.com/mcp`. If you serve your apps from a separate API host with `PUBLIC_API_URL`, the MCP endpoint is not on that host; it answers `404` there, like the console's own routes. It speaks the Streamable HTTP transport over `POST`, statelessly: there is no session to keep, and every request carries its credential. Clients on protocol revision `2026-07-28` and clients on the 2025 revisions are both served. A client connects in one of two ways: with OAuth, where you approve it in your browser and it never holds a key, or with a management key you create and give it. Either way it gets the same tools, under the same rules. ## Connect with OAuth Give the client the endpoint URL and nothing else. Most MCP clients then sign in on their own. On your own deployment this needs the console's address configured; see [Configuration](https://docs.appaigateway.com/self-hosting/configuration#mcp-clients). 1. The client's first request is refused with `401` and a challenge naming `https://console.appaigateway.com/.well-known/oauth-protected-resource/mcp`. That document names your console's address as the authorization server, and the client reads where to send you from `/.well-known/oauth-authorization-server`. 2. The client opens your browser at the console's consent page. It shows the app's name as the app declares it, the domain that vouches for that name, and what the app asks for: **Read only** or **Manage**. 3. You decide: - **Signed in:** pick which of your accounts the app may use and what it may do, then **Allow**. The grant starts at what the app asked for, and you may give it less or more; a **Read only** connection can use every reading tool, and every change tool refuses it with `grant_insufficient`. A **Manage** connection can do what your role allows. - **Not signed in:** sign in, or create an account, and you come back to the same page. Google sign-in is offered where the deployment has it. - **Continue without an account**, where it is offered: see below. - **Deny** sends the app away with nothing. 4. The browser returns to the app, which exchanges what it received for its tokens. From then on it calls `/mcp` with them, and renews them by itself. The consent page is open for ten minutes. Each request a connection sends is attributed to MCP and to the app it was issued to. ### Continue without an account You can start without signing up: **Continue without an account** creates a new account nobody has claimed yet, and connects the app to it. It is the same kind of account the [CLI](https://docs.appaigateway.com/automation/cli) creates the first time it is used without signing in: - The connection always gets the **Manage** grant, because it is the only way into that account. If the app asked for **Read only**, the page says so. - On the hosted service, an account nobody has claimed is deleted unless a person claims it; the consent page shows when, and `get_account` reports it to the agent. - To keep it, ask the agent to call `claim_account`, open the link it gives you, and create your sign-in there. The connection keeps working afterwards, and you will find it on your **Access** page. - On a self-hosted deployment it is offered only before anyone has set the deployment up: the first connection's account becomes the deployment's account, exactly as the CLI's first account does, and the door closes after it. On the hosted service, each network address can create a few such accounts a day, counted together with the CLI's. ### How a client identifies itself An app identifies itself with an `https` URL as its `client_id`: the address of a small JSON document describing it, its Client ID Metadata Document. The gateway fetches it when the app asks to connect, and only the redirect addresses it lists can receive the result. The name the document declares is the app's own claim, which is why the consent page always shows it beside the domain that served it. A self-hosted deployment can turn this off with `OAUTH_CIMD`, and can register apps itself with `OAUTH_CLIENTS`, an advanced option for clients that cannot publish such a document; see [Configuration](https://docs.appaigateway.com/self-hosting/configuration#oauth_cimd). ### Tokens and how long they last | What | How long | | --- | --- | | Access token (`agw_oat_…`) | 10 minutes | | Refresh token (`agw_ort_…`) and the connection itself | 30 days, restarted each time the app refreshes | | The code the browser brings back | Once, within 10 minutes | A connection the app uses at least once a month lives until you revoke it. Every refresh replaces both tokens; presenting an old refresh token again, after a short grace period, ends the whole connection, because only a stolen copy would. Presenting the newest refresh token within 5 seconds of the refresh that issued it is answered `429` with `slow_down` and a `Retry-After` header, and that token stays good; presenting the previous one within the grace period instead answers the same tokens that refresh did. The same access token also works as a bearer token on the management API under `/v1/admin`, with the same grant, though a client discovers how to authorize only from `/mcp`: the API's refusals carry a `Bearer realm="management"` challenge that names no metadata. Neither ever reaches your keys and connections themselves: those are managed only in the console. ### Revoking a connection The console's **Access** page lists every connection beside your management keys: the app's name and domain, its grant, the account, when it was created and when it lapses unless renewed. **Revoke connection** ends it at once, and the app has to be approved again to come back. An app can also end its own connection at `/oauth/revoke`. ### Endpoints All of them are on the console's address, never on a separate API host: | URL | What it is | | --- | --- | | `/.well-known/oauth-protected-resource/mcp` | Where the MCP endpoint says to authorize; also at `/.well-known/oauth-protected-resource` | | `/.well-known/oauth-authorization-server` | The authorization server's metadata | | `/oauth/authorize` | Where the app sends the browser: the authorization-code flow with PKCE (`S256`) | | `/oauth/token` | The code exchange and refresh, form-encoded | | `/oauth/revoke` | Revocation, form-encoded | The scopes are `read` and `manage`, and `resource` is the console's address or the MCP endpoint's. There is no dynamic client registration and no client secret: every app is a public client. ## Connect with a management key Create a management key on the console's **Access** page and give it to the client as a bearer token, in an `Authorization: Bearer` header. Choose the key's grant for what the client should be able to do: - **Read only** for a client that only looks. Every reading tool works with it, and every change tool is refused with `grant_insufficient`. - **Manage** for a client that will also change things. It can do what the key's owner's role allows: an owner's or admin's key can make every change below, a member's none. Keep the key out of the client's configuration file where you can. A client that expands environment variables, such as Claude Code with a project `.mcp.json`, can read it from the environment: ```json title=".mcp.json" { "mcpServers": { "app-ai-gateway": { "type": "http", "url": "https://console.appaigateway.com/mcp", "headers": { "Authorization": "Bearer ${AGW_MANAGEMENT_KEY}" } } } } ``` A request without a bearer token, or with one that is not a live management key or access token, is answered `401` with a `WWW-Authenticate: Bearer realm="management"` challenge that also names where to authorize, when the deployment runs OAuth. The server never accepts a console sign-in cookie. Revoking the key on the **Access** page ends the client's access at once. Each request a client sends with your key is attributed to MCP, whatever the client calls itself. ## Reading tools These tools change nothing and never send a request to a provider. Tools about one app take its id as `app`. | Tool | What it returns | | --- | --- | | `get_account` | Your account and the deployment it lives on; for an account nobody has claimed yet, its deadlines | | `get_capabilities` | The provider types and provider gateway types the deployment supports | | `list_models` | The priced models, with per-token prices and retirement dates | | `list_providers` | Your providers' metadata; never a key, only its last characters | | `get_provider` | One provider's metadata, by id or slug | | `list_provider_gateways` | Your provider gateways' metadata | | `list_apps` | Your apps, with their status and a month's usage | | `get_app` | One app's whole stored document, with its revision | | `validate_app` | Whether an app document would be accepted, as a new app or as an update of one, without saving it | | `check_app` | Whether an app can serve requests, without sending one | | `get_app_snippet` | The first request an app can send, without a credential in it | | `list_app_keys` | A server app's keys, as metadata; never a key's value | | `list_app_users` | An app's end users, a page at a time | | `get_app_user` | One end user, with status and usage | | `list_app_events` | An app's proxied requests, newest first | | `list_auth_events` | An app's sign-in and App Attest attempts, newest first | | `get_auth_event_summary` | An app's sign-in outcomes per day | | `list_rejection_events` | Samples of the requests the gateway refused for an app | | `get_usage` | One month's totals, for all your apps or for one | | `get_usage_breakdown` | An app's usage grouped by model, provider, user or another dimension | | `get_usage_timeseries` | An app's usage per day | ## Change tools | Tool | What it does | | --- | --- | | `get_operation` | Where a change a tool started stands: pending, completed with what it created, or expired | | `add_provider`, `add_provider_gateway` | Start adding a provider or a provider gateway; you enter its key or token in your browser | | `update_provider`, `update_provider_gateway` | Change a provider's name, routing, pricing or status, or rename a provider gateway | | `rotate_provider_key`, `rotate_provider_gateway_key` | Start replacing a key or token; you enter the new one in your browser | | `remove_provider`, `remove_provider_gateway` | Delete one for good, with its id repeated as `confirm` | | `add_app` | Create an app; a server app's key is shown to you on a page, never to the agent | | `update_app` | Replace an app's document, at the revision it was read at | | `remove_app` | Delete an app for good, with its id repeated as `confirm` | | `add_app_key` | Create another key for a server app, shown to you on a page | | `revoke_app_key` | Revoke one of a server app's keys | | `block_app_user`, `unblock_app_user` | Block or unblock one of an app's users | | `contact_support` | Email human support for you, where your deployment offers it; the reply comes to your email address | | `claim_account` | Start claiming an account nobody owns yet, in your browser | ### Creating takes two calls `add_app` and `add_app_key` create something an agent may need to ask for again after a lost answer, so each takes two calls. The first, without `handle`, checks the request, creates nothing, and answers a `handle` and the reserved operation's `id`. The second, with the same arguments and that `handle`, creates it — once. Calling again with the same handle answers the same result with `replayed: true` and creates nothing more, so an agent can always retry. A handle lapses after 15 minutes unused, and one sent with different arguments is refused with `operation_mismatch`. When the first call answers a `notice`, the same request was already made within the last hour, and the notice names it so the agent can look before creating a second. Every tool answer that names an operation calls it `id`, and `get_operation` takes that `id`. A `handle` is only ever what the second call of a create takes. ### A key is revealed on a page you open A key is revealed on a page a person opens, never in a tool result. When `add_app` creates a server app, or `add_app_key` a key, the answer carries `reveal_url`, a page on your console. Open it signed in as an owner or admin of the account, press **Reveal the key** and copy it: it is shown once, within 15 minutes of its creation, and nothing reveals it again. Nobody else can open it with the link alone, and the agent never sees the key. ### Keys and tokens are entered in your browser `add_provider`, `add_provider_gateway`, `rotate_provider_key` and `rotate_provider_gateway_key` take everything except the secret, and answer a URL. Open it in your browser, check what is about to be stored, enter the key or token there and approve. The agent waits with `get_operation`, passing the answer's `id`, until the change completes, or until it lapses after 15 minutes or you decline it. A tool called with a key or token as an argument refuses it. ### How secrets are kept out of tools A field is named like a credential when its name, lower-cased and with `_` and `-` removed, is one of `secret`, `token`, `apikey`, `password`, `passwd`, `passphrase`, `credential`, `credentials`, `authorization`, `accesstoken`, `refreshtoken`, `idtoken`, `authtoken`, `sessiontoken`, `bearertoken`, `clientsecret`, `apisecret`, `secretkey` and `privatekey`. The whole name is compared, so `api_key`, `apiKey` and `Token` match, and `secretHint`, `tokenHint` and `max_tokens` do not. - **Arguments.** The four browser-step tools above, and `add_app`, `update_app` and `validate_app` for the app document they take, refuse a call with a field of such a name anywhere in its arguments: at any depth, inside a list, beside the documented arguments or inside them. The refusal names the field and never its value, and nothing is stored. - **URLs in a browser step.** Every string in a browser step's arguments is trimmed before anything is stored. One longer than any field of a browser step accepts is refused at once; the rest are read with the URL parser a browser uses. A `baseUrl` follows the rules a provider's base URL always follows: an `https://` address on a public domain name, with no credentials, query string, fragment or port. Any other string the parser reads as a URL is refused when it carries credentials (`https://user:pass@…`, however it is written) or when its query or fragment names a credential. Both are first percent-decoded, repeatedly, so an encoded delimiter such as `%3D` delimits, and then split on every `?`, `&`, `#` and `;`. A piece with an `=` is a parameter: its name is stripped of leading `?`, `#` and `/` and compared with the names above. A piece without one is a route or a flag and is not judged. So `?api_key=…`, `#access_token=…` and `#/callback?token=…` are refused, while `https://example.com/?version=2`, `https://example.com/#/settings/password` or a name like `OpenAI: production` is accepted. - **Results.** In every tool result, a field with a credential name is shown as `[redacted]` — the whole field, whatever it holds: a string, a number, a list or an object — however it was stored. An app you configured through the API or the CLI may carry provider-native parameters of any name, and an agent reads them as `[redacted]`. The one exception is the metadata of the key a tool just created — the `api_key` of an `add_app` or `add_app_key` result and `result.api_key` of a `get_operation` result (`id`, `name`, `key_prefix`, `created_at`) — shown when it holds nothing else. An `api_key` anywhere else, inside an app document included, is redacted. A field holding `null` stays `null`, and hint fields such as `secretHint` are shown as they are. ### Changes without a browser step Updates carry the `revision` the agent read, so a change someone made since is refused rather than overwritten. Deletions repeat the id as `confirm`; prefer disabling an app or a provider, which you can undo. Saved app and provider changes take up to a minute to affect live traffic. User blocks refuse new token exchanges immediately and existing gateway tokens within ten seconds. If `get_app` returns `[redacted]` inside configuration, do not send that marker back or omit those fields from a full replacement. Have the person make the edit through a client that can preserve the original values. ## Results and refusals Each result has a one- or two-sentence summary for the agent to read and the same body the API answers with as structured content. A refusal is a tool result marked as an error, carrying the gateway's error code, its message and what to do next, such as `app_not_found` with "Call list_apps for the ids of your apps". Only a missing or invalid credential is answered with an HTTP error; a **Read only** credential asking for a change is a tool result too. The server also publishes `agw://guide`, a resource with the full rules for working with the gateway over MCP. Clients that read resources can hand it to the agent; its contents are the same as what the server tells every client when it connects, at length. ## Calling from a browser Most MCP clients are not browsers and send no `Origin` header; they are always served. A request from a web page is served only from the console's own origin or from one you list. On your own deployment, list the origins of browser-based MCP clients you trust in the `MCP_ALLOWED_ORIGINS` variable; see [Configuration](https://docs.appaigateway.com/self-hosting/configuration#mcp_allowed_origins). They receive CORS headers on `/mcp` and on the three `/oauth` endpoints, and every other origin is refused with `403`. The discovery documents are readable from any origin. If your deployment answers on more than one host name, set `CLI_CONSOLE_ORIGIN` to the console's address, so only that host serves `/mcp` and only its origin counts as the console's own. --- # Agent manual Source: https://docs.appaigateway.com/automation/agent-manual/ Everything an AI agent needs to manage providers, applications, users and usage through the API, in one page. Manage App AI Gateway on a person's behalf with the CLI or an existing MCP connection. This manual is also served as plain markdown at [https://docs.appaigateway.com/agents.md](https://docs.appaigateway.com/agents.md). Use [the documentation index](https://docs.appaigateway.com/llms.txt) to find task-specific guides and [OpenAPI](https://docs.appaigateway.com/openapi.json) for exact HTTP schemas. Prefer the connected server's capabilities and the installed CLI's `--help` when versions differ. ## Choose the connection before changing anything Hosted defaults: | Purpose | Address | | --- | --- | | Console, sign-in and browser handoffs | `https://console.appaigateway.com` | | App requests and management API | `https://api.appaigateway.com` | | MCP | `https://console.appaigateway.com/mcp` | For self-hosting, substitute that deployment's console and API origins; they may be the same. Do not deploy infrastructure unless the person asked for self-hosting. `GATEWAY_URL` below means the selected API origin, with no `/v1` suffix. Use the URLs returned by handoff operations instead of constructing them. With MCP, start with `get_account` and read `agw://guide`; see "MCP" below. For the CLI, install `@maxceem/agw` on Node.js 22.19 or later, then: ```sh agw deployment status --json --no-input agw account status --json --no-input ``` The CLI defaults to hosted only when no connection is saved. Inspect its reported URL, deployment and account; do not assume a previous task left the right connection active. A configured credential is not proof it is valid. - **Existing account:** use the saved connection if it matches. Otherwise run `agw account login --no-open --json --no-input`. To sign in to hosted again, use `agw deployment connect --cloud --no-open --json --no-input`. Without a supplied management key, this returns a pending browser login just like `account login`; have the person approve it before continuing. - **Existing self-hosted gateway:** use `agw deployment connect --url URL --no-open --json --no-input`. - **New hosted account, when requested:** on a fresh hosted connection, the first provider, provider-gateway or app creation initializes an unclaimed account. Do not use this path to recover a failed login to an existing one. Check account status for its expiry, start `agw account claim --no-open --json --no-input`, and give the person the URL to keep the account. Help, status, lists, validation and dry runs never create accounts. Claiming requires the person to create their sign-in in their own browser; do not open or approve the claim yourself. Claiming preserves the agent's access. ## CLI task workflow 1. Read `agw provider list --json` and `agw app list --json`. Reuse the IDs and slugs returned; inspect a target with `agw app show --json`. 2. Add a missing provider with `agw provider add --type openai --browser --no-open --json --no-input`. Give the person the returned URL and wait with `agw operation wait --json`. Never request the secret in chat. 3. Read the command's `--help` and make the requested change. For iOS, supply the person's Team ID and Bundle ID. Set `--attest-environments production` for a production-only app: CLI flag-based creation otherwise enables both production and development. For server keys, use `--key-output` with an unused private path, or retain the CLI's managed private output file. 4. For a new app file, use `agw app validate --file app.json --json`. For an existing app, use `agw app update --file app.json --dry-run --json` so validation is performed as an update of that app. The file contains only `name`, `config` and `status` from `result.app`; exclude metadata such as `id`, `revision` and timestamps. Keep every existing configuration field. 5. Apply the requested update, then reread the app. File updates replace the whole document. The CLI reads a revision when the update command starts; this protects against a competing write during that command, not edits made since an earlier file export. Refresh and merge an old file first. 6. Run `agw app check --json` and `agw app snippet --json`. Resolve any placeholders named in `result.notes`. The check reads configuration only: no provider key, physical device, sign-in or entitlement is exercised. Do not send inference unless the person requested it. 7. Report the app ID, provider slugs, API base URL, key file location, checks and remaining work. Report pending handoffs as pending, never completed. For every unattended command, use `--json --no-input`; for browser handoffs, add `--no-open`. `--json` returns `{schemaVersion:1,ok:true,context,result}` or `{schemaVersion:1,ok:false,error}`. Inspect both `ok` and the result: a successful pending operation still needs the browser step and polling. Exit codes are 0 success, 2 invalid input, 3 remote failure, 4 state/authentication recovery and 5 wait timeout. A timeout does not cancel the operation. Follow `error.nextAction`, preserving retry state after a lost response. Use `agw usage show --month YYYY-MM --json` for retained account totals, including deleted apps. Configuration files transfer neither credentials nor usage history between deployments. ## Signing in - `agw account login --no-open --json` opens a login and returns `state: "pending"` with `url`, `userCode` and `id`. Give the person the URL and the pairing code. They open the URL in their own browser, check that the page shows the same code, and approve. Then run `agw operation wait --json`; the CLI stores its new management key and prints only non-secret fields. Without `--no-open` the CLI also opens the page in the local browser when one is available (not over SSH, on Linux with no display, or without a terminal), and without `--json` it waits for the approval itself and prints the pairing code to stderr. - For your own deployment, `agw deployment connect --url URL` runs the same login against that URL. - A declined login fails with `operation_denied`; an expired one with `operation_expired`. Start a new login; do not retry the old id. - In CI, or when the person gives you an existing management key, use the `AGW_MANAGEMENT_KEY` environment variable. When it is set it is the credential for every command, ahead of any saved login, and the environment key is not saved to disk; `agw account login` refuses with `environment_credential`. Alternatively pipe the key once with `agw account login --key-stdin` (or `agw deployment connect --url URL --key-stdin`) to save it. Never pass a key as an argument. - `agw account logout --json` revokes the saved key and removes it locally. Its result is `{loggedOut: true, revoked: true|false}`; `revoked: false` means the key was already refused or the deployment could not be reached. - Every key a CLI login creates appears on the console's Access page, tagged CLI and labelled with the machine it was issued to, for example `CLI on mac-studio`. The person can revoke it there at any time. ## MCP If you are connected to the gateway as an MCP server, at the console's address followed by `/mcp` (`https://console.appaigateway.com/mcp` when hosted), you can work with the same account through its tools instead of the CLI. A client connects with OAuth by itself: the person approves it on the console's consent page, choosing the account and whether you may read or manage, or continues without an account, which creates an unclaimed account your connection manages. An unclaimed account expires unless a person claims it: when `get_account` says so, call `claim_account` and give the person its URL. A management key as a bearer token works too. Use the tools: `get_account` first, then `list_providers`, `list_apps`, `get_app`, `validate_app`, `check_app`, usage and event tools, and the change tools. `add_app` and `add_app_key` take two calls: the first reserves and creates nothing, the second, with the same arguments and the answer's `handle`, creates it once, and repeating it is safe. An answer that names an operation calls it `id`; that is what `get_operation` takes. A key is revealed on a page a person opens, never in a tool result: give them `reveal_url`. If a read contains `[redacted]` configuration, do not send that marker back in an update or drop those fields from a full replacement; have the person preserve them through the console or another authorized client. A provider key or gateway token is never a tool argument: `add_provider` and the rotate tools answer a URL where the person enters it, and `get_operation` reports when they have finished. A refusal is a tool result marked as an error with the gateway's code and the next step to take. The server's `agw://guide` resource has its full rules, and the MCP server guide at `/automation/mcp` lists every tool. The API reference below is useful for integrations that do not use the CLI. ## What the gateway is App AI Gateway is a proxy between a person's applications and AI providers (OpenAI, Anthropic, Gemini, xAI and others). The person adds **provider keys** once; they are shared by all their apps. They create **applications**; each app has a permanent ID, an authentication policy, a proxy policy saying which providers, paths and models it may use, optional custom endpoints, and limits on its users. Client apps call `{GATEWAY_URL}/v1/apps/{app}/proxy/{provider_slug}/{provider_path}` with the provider's own request format. You manage all of this through the admin API. ## Authentication - Use the explicitly selected gateway URL; the hosted API is `https://api.appaigateway.com`. `GET /v1/healthz` checks basic availability. - Direct integrations send a management credential as `Authorization: Bearer` using their protected credential store. The CLI uses `AGW_MANAGEMENT_KEY` when it is set, and otherwise the key its active connection saved. - All admin routes are under `/v1/admin/`. Request and response bodies are JSON with `Content-Type: application/json`. - A management key has the current role of its owning identity. It does not expire. It cannot perform human-only actions such as claiming an account, changing human ownership, or creating, listing and revoking other management keys, which are console-only and answer `403 session_required` to a key. A key can revoke only itself, with `DELETE /v1/cli/credential`; that is what `agw account logout` sends. ## Rules 1. Never print, log or echo any secret: management keys, provider keys, application API keys. When a call returns a key, store it in a private file where the person asked, then report its location. 2. Never send `id` when creating an application. The gateway assigns the ID from the name and returns it as `app.id`. IDs cannot be changed. 3. Validate an application body before writing it: a new application with `POST /v1/admin/app-drafts/validate`, an existing one with `POST /v1/admin/apps/{app}/validate`. 4. `PUT /v1/admin/apps/{app}` replaces the whole application. Always `GET` first, modify the returned `app.name`, `app.config` and `app.status`, and send all three back together with the `app.revision` from that read. A missing revision returns 400; a stale revision returns 409. Reread and merge. 5. Read before you write. List providers and apps before changing anything, so you use slugs and IDs that exist. 6. App and provider changes take up to one minute to apply to live traffic. Blocking an authenticated user refuses new token exchanges immediately; existing gateway tokens stop working within ten seconds. Header-based users must be blocked in the backend. 7. Prefer reversible actions. Disable an app or provider rather than deleting it unless deletion was asked for. Deleting a provider also deletes its custom prices; deleting an app removes its users and keys. 8. Do not test the setup by sending provider requests unless asked, because each one costs the person money at the provider. ## Errors Every refusal is JSON: ```json { "error": { "code": "slug_taken", "message": "…", "data": { } } } ``` | Status | Codes | Meaning | | --- | --- | --- | | 400 | `invalid_request` | The body is malformed or violates a rule; `message` says which. A create body that carries `id` gets this | | 401 | `auth_required` | Missing or wrong management key | | 403 | `forbidden` | The owning identity's role does not permit this operation | | 403 | `grant_insufficient` | The key is read-only and this call would change something; ask the person for a key with the manage grant | | 404 | `not_found`, `app_not_found` | No such app, provider, gateway or key | | 409 | `slug_taken` | A provider already has this slug; supply another | | 409 | `gateway_in_use` | A gateway still has providers routed through it | | 409 | `provider_gateway_managed` | This provider's key lives in a gateway; rotate the gateway token instead | | 400 | `provider_not_supported_by_gateway` | That provider type cannot be routed through that gateway | | 400 | `provider_key_invalid` | The provider refused the key during a test | | 400 | `app_revision_required` | Include the revision from the app read | | 409 | `app_revision_conflict`, `conflict` | Reread the resource and merge the requested changes before retrying | | 502 | `provider_unavailable` | The gateway could not decrypt a stored key | ## Workflow for setting up an app 1. `GET /v1/admin/providers`. Note which provider types exist and their `slug` values. 2. If a provider is missing, use a CLI or MCP browser handoff so the person can enter the key privately. 3. Compose the application body from the reference below. 4. `POST /v1/admin/app-drafts/validate` with the body. Fix every error. 5. Create through the [retry-safe operation](#retrying-creation-safely), using `kind: "app.add"` and that body as `payload`. Read `result.app.id` and store `result.api_key.key` privately for a server app. A plain `POST /v1/admin/apps` instead returns `app` and `api_key` at the top level, but cannot safely be retried after a lost response. 6. Report the app ID, the base URL `{GATEWAY_URL}/v1/apps/{app.id}/proxy/{provider_slug}/{provider_path}`, and where the key went. Do not include the key. ## Providers A provider row is one key for one provider type, or a reference to a gateway that holds the key. Its `slug` is the URL segment clients use. The first provider of each type defaults to the type name as its slug (`openai`, `anthropic`, `gemini`, `xai`, `perplexity`, `deepseek`, `groq`, `mistral`, `together`, `fireworks`, `cerebras`, `moonshot`, `huggingface`, `baseten`, `bytedance`, `openrouter`). A second provider of the same type must be given a slug, or the create answers `409 slug_taken`. | Call | Body | Notes | | --- | --- | --- | | `GET /v1/admin/providers` | | Returns `providers[]` with `id`, `type`, `slug`, `name`, `secretHint`, `providerGatewayId`, `baseUrl`, `pricing`, `route`, `capability`, `revision`, `status`. `capability` lists the `apiStyles` and `endpointStyles` the instance serves on its route and the `modelPrefix` the route adds | | `POST /v1/admin/providers/test` | `{ type, secret }` or `{ type, providerGatewayId }`, optional `baseUrl` | Dry run. `validated: true` means the provider accepted the key. `validated: false` with a `reason` means nothing was proven. A refused key answers `400 provider_key_invalid` | | `POST /v1/admin/providers` | `{ type, name, secret }` or `{ type, name, providerGatewayId }`; optional `slug`, `baseUrl`, `pricing`, `gatewayRoute` | Exactly one of `secret` and `providerGatewayId`. `baseUrl` only with `secret` | | `PUT /v1/admin/providers/{id}` | `revision` plus any of `name`, `secret`, `baseUrl`, `pricing`, `gatewayRoute`, `status` | `revision` is the value from the provider read this edit is based on. `secret` rotates the key in place. A new non-null `baseUrl` requires `secret` in the same call. `pricing: null` clears prices. `status: "disabled"` pauses without freeing the slug | | `DELETE /v1/admin/providers/{id}` | | Permanent. Frees the slug, deletes custom prices | `pricing` is per model, in USD per million tokens: ```json { "my-model": { "input": 0.5, "output": 1.5, "cached_input": 0.05 } } ``` `input` and `output` are required. Any other per-token rate the catalog uses is optional under the same name: `cached_input`, `cache_write`, `cache_write_1h`, `image_input`, `audio_input`, `video_input`, `image_output`, and the `long_*` rates, which also need `long_context_threshold`. A custom price replaces the catalog's rates for that model and inherits none of them: a rate left out bills as on a catalog entry without it, so a cache rate falls back to `input`. A model that is in neither the built-in catalog (`GET /v1/admin/prices`) nor the provider's `pricing` cannot be used by any app: requests answer `400 pricing_not_configured`, and an app configuration that names it in `allowed_models`, `model_rewrites` targets or endpoints is rejected on validation. OpenRouter reports its own cost and needs no price, and so does xAI on Chat Completions, Responses and image generation, directly or through Cloudflare; its Messages API, `v1/stt` and the Vercel route still need one. The catalog prices OpenAI's `gpt-image-*` and Gemini's image models per token, with separate image rates, and BytePlus Seedream 5.0 flash, 5.0 lite, 4.5 and 4.0 per generated image. Other image models billed per image or per megapixel, such as Together AI's and Seedream 5.0 pro, are not supported, and per-token `pricing` cannot express them. Custom `baseUrl` points an `openai`-type provider at an OpenAI-compatible server. It requires `https`, a public hostname, the default port and no query. The client path is appended literally: use `https://my-vllm.example.com/` with `v1/chat/completions`, or Azure's `https://RESOURCE.openai.azure.com/openai/` with that same path. Do not duplicate `v1/`. See [Custom base URLs](https://docs.appaigateway.com/providers/custom-base-urls). ### Gateways A gateway row is a connection to a Cloudflare AI Gateway or Vercel AI Gateway whose token is stored once and shared by providers routed through it. | Call | Body | | --- | --- | | `GET /v1/admin/provider-gateways` | | | `POST /v1/admin/provider-gateways/test` | `{ type: "cf_aig", accountId, gatewayId, token }` or `{ type: "vercel", token }` | | `POST /v1/admin/provider-gateways` | Same plus `name` | | `PATCH /v1/admin/provider-gateways/{id}` | `{ name, revision }` | | `POST /v1/admin/provider-gateways/{id}/rotate` | `{ token, revision }` | | `DELETE /v1/admin/provider-gateways/{id}` | Refused with `409 gateway_in_use` while any provider references it | Provider and gateway updates use the revision returned by their list route. A missing or invalid revision returns 400. If another edit wins first, the write returns `409 conflict`; read the row again and merge before retrying. Cloudflare routes `openai`, `anthropic`, `xai`, `gemini`, `perplexity`. Vercel routes those plus `deepseek` and `moonshot`, and accepts only the paths `v1/responses`, `v1/chat/completions` and `v1/messages`. ## Applications | Call | Body | Notes | | --- | --- | --- | | `GET /v1/admin/apps` | | `apps[]` with each app's month-to-date usage | | `GET /v1/admin/apps/{app}` | | `{ app: { id, name, config, status, revision, created_at, updated_at, warnings } }` | | `POST /v1/admin/app-drafts/validate` | `{ name, config, status? }` | `{ valid: true }` or a 400 naming the problem. For an app that does not exist yet | | `POST /v1/admin/apps/{app}/validate` | `{ name, config, status? }` | `{ valid: true, app_id }` or a 400 naming the problem. Judged as an update of that app; 404 if it does not exist | | `POST /v1/admin/apps` | `{ name, config, status? }` | Returns the app plus `api_key` (server apps only, once) | | `PUT /v1/admin/apps/{app}` | `{ name, config, status?, revision }` | Full replace; `revision` must be the one the last read returned | | `DELETE /v1/admin/apps/{app}?confirm={app}` | | Query repeats the app ID. Removes users, keys and diagnostics; retains usage history in account totals | | `GET /v1/admin/apps/{app}/keys` | | Server apps only | | `POST /v1/admin/apps/{app}/keys` | `{ name }` | Returns `key` once | | `POST /v1/admin/apps/{app}/keys/{key}/revoke` | | | `status` is `active` or `disabled`. A disabled app refuses every request with `403 app_disabled` and keeps everything. An app's `warnings` describe behaviour that will stop on the stated date: each has a `code`, a `message`, and usually `until` and `docs_url`. Tell the person about every warning and help them act before its date. Warnings are read-only: leave `warnings`, `id`, `created_at` and `updated_at` out of the body you write back, or the write is refused, and keep the `revision` from the read, which an update requires. ## Application configuration reference `config` is one JSON object with four top-level keys. Unknown keys are rejected. ```json { "authentication": { … }, "routing": { … }, "limits": { … }, "endpoints": { … } } ``` ### `authentication` Discriminated on `type`. **iOS application** (`apple_app_attest`): | Key | Type | Meaning | | --- | --- | --- | | `type` | `"apple_app_attest"` | The app proves itself with Apple App Attest | | `app_attest.team_id` | string | Apple Team ID | | `app_attest.bundle_id` | string | Bundle ID | | `app_attest.environments` | `["production"]`, `["development"]` or both; optional | Which App Attest environments are accepted. Omitted means production only | | `app_attest.verify_on` | `"every_request"` or `"token_exchange"`; optional | When the install's App Attest assertion is verified. `every_request`, the default when omitted: every request carries `X-AppAttest-Proof`, which needs the Swift package 1.4 or later. `token_exchange`: only at the hourly token exchange, and requests carry the token alone. Apps created before this setting was added were set to `token_exchange`. Switch an app to `every_request` only after the person confirms every build in use is on package 1.4 or later; older builds are refused from then on | | `end_user` | object, required | `{ "source": "issuer", "issuer": {…} }` for signed-in users, or `{ "source": "app_install" }` to count each install as a user | **Server application** (`api_key`): | Key | Type | Meaning | | --- | --- | --- | | `type` | `"api_key"` | The backend proves itself with an API key | | `end_user` | object, required | `{ "source": "none" }` for no user identity. `{ "source": "header", "header": "x-end-user-id" }` when the backend names the user in a header. `{ "source": "issuer", "issuer": {…} }` for signed-in users, in which case the key is exchanged with the user's ID token for a gateway token | **The `issuer` object** (signed-in users): | Key | Type | Meaning | | --- | --- | --- | | `jwks_url` | URL | Where the identity provider publishes its signing keys | | `issuer` | string or string[] | Accepted `iss` values. Required | | `audience` | string or string[] | Accepted `aud` values. Required | | `user_id_claim` | string | Claim used as the user ID. Use `"sub"` | | `required_claims` | array | Entitlement requirements, each `{ "path": "...", "contains": "x" }` or `{ "path": "...", "equals": value }`. `contains` may be a list of alternatives. Empty array for none | | `max_token_lifetime_seconds` | integer | Reject tokens whose `exp - iat` exceeds this. Use `86400` | | `token_header` | string, optional | Rarely needed | | `provider` | `"firebase"`, `"supabase"`, `"auth0"`, `"clerk"`, `"custom"`, optional | Lets the console show the preset. Set it to match what you configured | | `entitlement` | `"revenuecat"` or `"custom"`, optional | Same, for the subscription check | Preset values for common identity providers: | Provider | `jwks_url` | `issuer` | `audience` | | --- | --- | --- | --- | | Firebase | `https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com` | `https://securetoken.google.com/` | `` | | Supabase | `https://.supabase.co/auth/v1/.well-known/jwks.json` | `https://.supabase.co/auth/v1` | `authenticated` | | Auth0 | `https:///.well-known/jwks.json` | `https:///` (trailing slash required) | the API identifier | | Clerk | `https:///.well-known/jwks.json` | `https://` | the `aud` the JWT template sets | | Sign in with Apple | `https://appleid.apple.com/auth/keys` | `https://appleid.apple.com` | the bundle ID | A RevenueCat entitlement check is `{ "path": "revenueCatEntitlements", "contains": "" }` with `"entitlement": "revenuecat"`. ### `routing` | Key | Type | Meaning | | --- | --- | --- | | `providers.mode` | `"all"` or `"selected"` | `all` allows every provider the account has, now and later. `selected` allows only those in `providers.selected` | | `providers.selected` | object keyed by provider slug | Required when mode is `selected`. Each value is a policy object below. Every slug must be an existing provider on create | | `model_rewrites` | object | Client model name to real model name. Required key; use `{}` for none. Targets must be priced | Policy object for one provider: | Key | Type | Meaning | | --- | --- | --- | | `allowed_paths` | array | Empty allows inference APIs, and each provider's own image, embedding, speech and legacy completion endpoints; the text ones honour `max_output_tokens`, the rest are never capped. A non-empty array allows only its entries, as strings like `"v1/responses"` or objects `{ "path", "fixed_model"?, "clamp"? }`. `clamp` is one of `responses`, `chat_completions`, `gemini_native`, `anthropic`, `none` | | `allowed_models` | string[] | Empty allows every priced model | | `max_output_tokens` | positive integer, optional | Requests above it are refused; requests without an output limit get it injected | ### `limits` Optional. Omit for no limits. Every field is `null` (unlimited) or a number. Non-null `per_user` limits require an app that identifies users; an all-null block is accepted without user identity. Request limits must be positive; a spend budget may be `0` to stop all spend. On an iOS app, `per_user` request limits count per install (one copy of the app on one device), and so does the budget unless `per_user.spending.scope` is `"user"`, which needs `end_user.source` `issuer`. `scope` defaults to `"install"` on iOS apps and `"user"` on server apps, where `"install"` is refused. The gateway writes the default in. ```json "limits": { "per_user": { "requests": { "per_minute": 10, "per_day": 300 }, "spending": { "monthly_usd": null } }, "per_app": { "requests": { "per_minute": null, "per_day": null }, "spending": { "monthly_usd": 100 } } } ``` ### `endpoints` Optional. Keyed by slug (`^[a-z0-9-]{1,64}$`). Each endpoint: | Key | Type | Meaning | | --- | --- | --- | | `api_style` | `"responses"`, `"chat_completions"`, `"anthropic_messages"`, `"gemini_native"`, `"audio_transcription"`, `"image_generation"`, `"embeddings"` or `"gemini_embedding"` | Request format the client sends | | `kind` | `"text"`, `"image"`, `"speech"`, `"transcription"` or `"embedding"`, optional | What the endpoint does. Defaults to the format's only kind; `gemini_native` carries text (the default), image and speech | | `provider` | provider slug | A provider whose type and route serve the format: OpenAI and xAI for `responses`, the OpenAI-compatible types and OpenRouter for `chat_completions`, Anthropic for `anthropic_messages`, Gemini for both Gemini formats, OpenAI, xAI and Groq for `audio_transcription`, OpenAI, xAI and ByteDance for `image_generation`, OpenAI for `embeddings` | | `model` | string | Must be of the endpoint's kind and priced. A model only custom pricing covers counts as text. On a provider that reports its own cost, an unpriced model is accepted | | `params` | object, optional | Deep-merged over the client body, server wins. Every format but `audio_transcription` | | `max_output_tokens` | positive integer, optional | Text endpoints only | | `fallback` | array of `{ provider, model }`, optional | Tried in order when the primary fails before streaming starts. Same format and kind | `listModelPrices` gives every catalog model's `kind`: `text`, `image`, `speech`, `transcription`, `embedding` or `realtime`. Pick an endpoint's models by it; a `realtime` model serves no endpoint. ### Complete examples A server app for a backend that names its users, restricted to one OpenAI model: ```json { "name": "Search service", "config": { "authentication": { "type": "api_key", "end_user": { "source": "header", "header": "x-end-user-id" } }, "routing": { "providers": { "mode": "selected", "selected": { "openai": { "allowed_paths": ["v1/responses"], "allowed_models": ["gpt-5.6-sol"], "max_output_tokens": 4096 } } }, "model_rewrites": {} }, "limits": { "per_user": { "requests": { "per_minute": 30, "per_day": 1000 }, "spending": { "monthly_usd": 10 } }, "per_app": { "requests": { "per_minute": 300, "per_day": 10000 }, "spending": { "monthly_usd": 100 } } } } } ``` An iOS app with Firebase sign-in, a RevenueCat entitlement, and a named endpoint: ```json { "name": "Calorie Tracker", "config": { "authentication": { "type": "apple_app_attest", "app_attest": { "team_id": "ABCDE12345", "bundle_id": "com.example.calorietracker" }, "end_user": { "source": "issuer", "issuer": { "jwks_url": "https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com", "issuer": "https://securetoken.google.com/calorie-1a2b3", "audience": "calorie-1a2b3", "user_id_claim": "sub", "required_claims": [{ "path": "revenueCatEntitlements", "contains": "pro" }], "max_token_lifetime_seconds": 86400, "provider": "firebase", "entitlement": "revenuecat" } } }, "routing": { "providers": { "mode": "all" }, "model_rewrites": {} }, "limits": { "per_user": { "requests": { "per_minute": 20, "per_day": 500 }, "spending": { "monthly_usd": 5 } }, "per_app": { "requests": { "per_minute": null, "per_day": null }, "spending": { "monthly_usd": 500 } } }, "endpoints": { "chat": { "api_style": "responses", "provider": "openai", "model": "gpt-5.6-luna", "params": { "reasoning": { "effort": "low" } }, "fallback": [{ "provider": "xai", "model": "grok-4.5" }] } } } } ``` ## Users | Call | Query or body | Notes | | --- | --- | --- | | `GET /v1/admin/apps/{app}/users` | `month=YYYY-MM`, `status=active|blocked`, `query=`, `limit=`, `offset=` | Each user with requests, tokens and cost for the month, and `installs`: the App Attest keys they hold, one per install | | `GET /v1/admin/apps/{app}/users/{user}` | | | | `POST /v1/admin/apps/{app}/users/{user}/block` | | New token exchanges are refused immediately; existing gateway tokens stop working within ten seconds. Users named by a backend header cannot be blocked | | `POST /v1/admin/apps/{app}/users/{user}/unblock` | | | ## Usage and events | Call | Query | Returns | | --- | --- | --- | | `GET /v1/admin/apps/{app}/usage` | `month=YYYY-MM` | Totals: requests, tokens, cost | | `GET /v1/admin/apps/{app}/usage/timeseries` | `from=`, `to=` (ISO dates) | Daily buckets by provider | | `GET /v1/admin/apps/{app}/usage/breakdown` | `by=`, `from=`, `to=`, `limit=` | Grouped totals. `by` is one of `model`, `model_author`, `provider`, `provider_slug`, `provider_gateway`, `credential_source`, `user`, `install`, `status`, `cost_source`, `route`, `endpoint`, `app_version` | | `GET /v1/admin/apps/{app}/events` | `limit=`, `status=`, `user=`, `install=`, `model=`, `before_id=` | Individual provider attempts, newest first, each with `install_id` on iOS apps. Page with `next_before_id` | | `GET /v1/admin/apps/{app}/auth-events/summary` | `days=` | Token exchange success rate, failures by cause, claim delay percentiles | | `GET /v1/admin/apps/{app}/auth-events` | `limit=`, `outcome=`, `before_id=` | Individual token exchanges and registrations | | `GET /v1/admin/apps/{app}/rejection-events` | `limit=`, `reason=`, `scope=`, `user=`, `from=`, `to=`, `before_id=` | Sampled requests stopped before a provider call; page with `next_before_id` | Usage event `status` values: `ok`, `provider_error`. Refusal sample `reason` values: `blocked_app_rate`, `blocked_app_budget`, `blocked_user`, `blocked_billing`, `blocked_replay` (a replayed App Attest proof). `scope` is `install`, `user`, `app` or `account`. Samples are limited to one per identity per minute, so sample counts are not exact request totals. Event `cost_source` values: `computed`, `reported`, `unresolved`. ## What clients send You do not normally call these, but you may need to explain them or write client code. - Proxy: `{METHOD} /v1/apps/{app}/proxy/{provider_slug}/{provider_path}` with the provider's own body. Credential: `Authorization: Bearer ` plus `X-App-Version` for iOS and signed-in-user apps, or `Authorization: Bearer agw_…` for a server app without user identity. When an iOS app's `verify_on` is `every_request`, every request also carries `X-AppAttest-Proof`, an App Attest assertion over that request; without it the gateway answers `401 assertion_required`. With `token_exchange` the token alone is enough. - Custom endpoint: `POST /v1/apps/{app}/endpoints/{slug}`. - Token exchange for a server app with signed-in users: `POST /v1/apps/{app}/auth/token` with `{ "api_key", "issuer_token" }`, returning `{ "access_token", "expires_in": 3600 }`. - iOS apps use the AppAIGateway Swift package 1.4 or later, which handles App Attest, the token exchange and, when the token exchange answers `verify_on: "every_request"`, the per-request proof. Send requests with `withAuthorizedRequest` (1.4.1 or later): it builds the request, hands it to the app's own send closure, and on a `401` discards the token and sends a rebuilt request once. Pass the request body to it, never set it afterwards. `authorizedRequest` builds the request alone, for an app that handles the retry itself. See the human documentation under Integrate your app. For Auth0's API-identifier preset, `issuer_token` must be a JWT access token for that API. Firebase uses its ID token. In every case the configured issuer and audience must match; see [User authentication](https://docs.appaigateway.com/applications/auth-policy/user-authentication). ## Contacting human support Where the deployment offers human support, you can write to it on the person's behalf: `agw support send` with the CLI, `contact_support` over MCP, or `POST /v1/admin/support` with a management key. Support replies to the person's own email address, never to you. 1. Only when the person asked you to contact support. Show them the exact message first, and send it only once they agree. Without a terminal, `agw support send` refuses to send unless you pass `--yes`, which says you have done this. 2. Choose the type: `bug` for something broken, `support` for a question or a request for help, `feedback` for an idea or a missing feature. 3. Write what happened, what they expected and what was tried. Put ids and codes in context so support can act without asking back: `--context app_id=my-app --context request_id=… --context error_code=…`. 4. Never include a key, token or password. A message or context value that looks like one is refused with `invalid_request`, and nothing is sent. 5. Tell the person the answer's `reference`, such as `SUP-7F3K2Q`. The same message sent again within ten minutes sends nothing new: the answer has `duplicate: true` and the first reference, so do not resend to make sure. 6. If the gateway refuses because the account cannot use human support, tell the person what the message says instead of retrying. ```sh agw support send --type bug --message "Streaming responses from my-app stop after about 30 seconds since Monday." \ --context app_id=my-app --context error_code=provider_timeout --yes --json --no-input ``` ## Reporting back When you finish a task, report: what you created or changed, by ID and slug; the client base URL for any app; where each secret was stored; and anything you could not do, such as a provider whose key was not available. Do not include secret values. ## Retrying creation safely A plain `POST` to create an app, application key, provider or provider gateway is not safe to repeat: a response lost in transit leaves you not knowing whether it landed. Send the creation as an operation instead: `POST /v1/cli/operations` with `{ kind, payload, token }`, where `kind` is `app.add`, `app.key.add`, `provider.add` or `provider-gateway.add`, `payload` is the body the plain endpoint takes (plus `app` for `app.key.add`), and `token` is a random URL-safe string of 32–256 characters you persist before sending. Repeat the same kind, payload and token to recover the original result; a different payload under the same token is 409. A new application key is returned in `result.api_key.key` for 15 minutes after creation; past that the result still names the resource and the key's id, and no second key is minted. Records are kept for 90 days. --- # Deploy to Cloudflare Source: https://docs.appaigateway.com/self-hosting/deploy-to-cloudflare/ Run your own gateway on your Cloudflare account. Deploy a GitHub fork from the Cloudflare form, then register with your email. For the simplest terminal setup, install `@maxceem/agw` and run `agw deployment setup --name my-gateway --no-domain`. It creates the resources, saves its recovery journal and connects the new gateway. See [CLI](https://docs.appaigateway.com/automation/cli). The button below is an alternative for a deployment maintained through a GitHub fork. Setup acts on your Cloudflare account through Wrangler, and reuses whatever Wrangler is already authorized with: an earlier `wrangler login`, or `CLOUDFLARE_API_TOKEN` in the environment. It opens a browser only when it finds neither. Setting `CLOUDFLARE_API_TOKEN` is what keeps a browser out of it; add `--no-input` so nothing else prompts either, and supply the values it would have asked for. The first deployment command downloads the gateway build belonging to that CLI version: one archive, published with the project's GitHub release. It is checked against a SHA-256 built into the CLI before anything is unpacked, and kept under `$XDG_CACHE_HOME/agw` (`~/.cache/agw`, or `%LOCALAPPDATA%\agw` on Windows), so later commands reuse it. An archive that does not match that hash is refused. On a machine with no route to GitHub, download `gateway-.tar.gz` from the release matching `agw --version` elsewhere, copy it over, and name it: ```sh agw deployment setup --name my-gateway --no-domain \ --release-archive "./gateway-$(agw --version).tar.gz" ``` The same hash is checked, so a file that travelled by hand is trusted no further than one that arrived over the network. `AGW_RELEASE_ARCHIVE` sets the same path for every deployment command. ## Prepare the deployment values Generate a vault key with `openssl rand -base64 32` and save it privately. The deployment ID is generated automatically. > **Back up the vault key** > > Losing `SECRET_VAULT_LOCAL_KEK_V1` makes stored provider credentials unreadable. > Keep it in a password manager or another protected backup. ## Deploy your fork [![Deploy to Cloudflare](https://docs.appaigateway.com/deploy-to-cloudflare.svg)](https://deploy.workers.cloudflare.com/?url=https://github.com/maxceem/app-ai-gateway) The form creates your fork and Worker and connects later pushes to Workers Builds. Fill in these values: | Field | Value | | --- | --- | | `SECRET_VAULT_LOCAL_KEK_V1` | Your private vault key. | | `SECRET_VAULT_MODE` | `local` | | `SECRET_VAULT_LOCAL_KEK_CURRENT_VERSION` | `1` | | `ALLOWED_REGISTRATION_EMAILS` | Your email address. Add more, separated by commas, for anyone else who should have an account of their own. | The deploy script generates the deployment ID and internal signing secrets once, applies migrations and deploys the console and Worker. Later builds read and reuse the deployed ID, including after a custom-domain change. You do not need to generate it or add it to the build environment. If the script cannot read the existing deployment, it stops rather than risking a change to its identity. Check `https://YOUR-WORKER.workers.dev/v1/healthz` and confirm `"vault":"ok"`. The generated deployment ID is available at `/v1/cli/capabilities`. ## Register Open `https://YOUR-WORKER.workers.dev` and create your sign-in with the email you listed in `ALLOWED_REGISTRATION_EMAILS`. It gets an account of its own; an address that is not listed cannot register. To let someone else in later, add their email to the variable in the Worker's settings. See [`ALLOWED_REGISTRATION_EMAILS`](https://docs.appaigateway.com/self-hosting/configuration#allowed_registration_emails). Follow [Quickstart](https://docs.appaigateway.com/quickstart) to add a provider and an app, or [Custom domain and rate limiting](https://docs.appaigateway.com/self-hosting/custom-domain) to add a hostname. --- # Deploy with Wrangler Source: https://docs.appaigateway.com/self-hosting/deploy-with-wrangler/ Deploy from a checkout with the deploy script, and keep deployment-specific settings in a profile. Use this path when you want to deploy from your own machine or from your own CI, or when your deployment needs settings that the one-click form cannot express: a custom domain, a fixed database, or the `kms` vault mode. ## Requirements - Node.js 22.19 or newer and the pnpm version declared in `package.json`. - A Cloudflare account. `pnpm install` brings Wrangler 4 with it; run `pnpm exec wrangler login` once so it can act on your account. - `openssl`, to generate the vault key. ## Deploy From a clean checkout: ```sh pnpm install cp .dev.vars.example .dev.vars openssl rand -base64 32 # paste the output into SECRET_VAULT_LOCAL_KEK_V1 in .dev.vars ``` `.dev.vars` is gitignored. Its `SECRET_VAULT_LOCAL_KEK_V1` encrypts provider credentials; back it up because losing it makes stored credentials unreadable. Then generate the initialization values, upload the secrets and deploy: ```sh pnpm run secrets:setup-local pnpm run secrets:upload pnpm run deploy ``` Run `pnpm run secrets:setup-local` to generate the immutable public `DEPLOYMENT_ID` and the internal signing secrets in ignored `.dev.vars`. For a profile, put its own ID in `.dev.vars.` or set it in that profile's plain `vars`. Preserve the ID on every deployment. The upload helper excludes it from secrets; deployment supplies a plain binding. Do not reuse a local development identity for a separate production deployment. The deploy script does the following, in order: 1. Builds the console. 2. Checks the required vault secrets. If one is missing and `.dev.vars` is present, it uploads only missing names, so the explicit `secrets:upload` above is a convenience rather than a requirement. 3. Creates `JWT_SECRET` and `BETTER_AUTH_SECRET` if they do not exist yet. Existing secrets are never overwritten. 4. Applies the database migrations. On the first run the database does not exist yet, so the script deploys once to provision it and then applies the migrations. 5. Deploys the Worker with the console bundled as static assets. Run it again at any time. It is safe to repeat and it is also how you update; see [Updating](https://docs.appaigateway.com/self-hosting/updating). ## Verify and sign in ```sh curl https://YOUR-WORKER.workers.dev/v1/healthz ``` Expect `"vault": "ok"`. Then open the console and create your sign-in with an email listed in `ALLOWED_REGISTRATION_EMAILS`, which is empty in the tracked `wrangler.jsonc`: set it in a [profile](#deployment-profiles) or in the Worker's settings first. See [`ALLOWED_REGISTRATION_EMAILS`](https://docs.appaigateway.com/self-hosting/configuration#allowed_registration_emails). ## Deployment profiles The tracked `wrangler.jsonc` is the complete configuration for a plain installation. It deliberately has no custom domain and no account-specific database ID. Those belong in a deployment profile. A profile is a gitignored `wrangler..overlay.jsonc` in the repository root that holds only the keys your deployment changes. It is not a complete Wrangler configuration. Every command merges it over `wrangler.jsonc` first, so shared sections such as the resource migrations list are never copied and cannot drift. The merge rules are small: - Objects merge recursively. - An array in the overlay replaces the base array. - `null` removes a key. ```jsonc title="wrangler.prod.overlay.jsonc" { "workers_dev": false, "routes": [{ "pattern": "console.example.com", "custom_domain": true }], "vars": { "ALLOWED_REGISTRATION_EMAILS": "you@example.com", "SECRET_VAULT_MODE": "kms", "SECRET_VAULT_LOCAL_KEK_CURRENT_VERSION": null }, "d1_databases": [ { "binding": "DB", "database_name": "app-ai-gateway-prod", "database_id": "00000000-0000-0000-0000-000000000000", "migrations_dir": "migrations" } ] } ``` This example switches the vault to `kms` mode, which is why it removes `SECRET_VAULT_LOCAL_KEK_CURRENT_VERSION`: a `local` variable present in `kms` mode is a configuration error. Keep `local` mode and drop those two `vars` lines if you only want the domain and the database. See [Configuration](https://docs.appaigateway.com/self-hosting/configuration) for both modes. Every script accepts the profile by name: ```sh pnpm run deploy --profile prod pnpm run deploy:dry-run --profile prod pnpm run db:migrate --profile prod ``` The merged result is written to `wrangler..generated.jsonc`, also gitignored, and handed to Wrangler with `--config`. Do not edit it; change the overlay instead. A profile reads its secrets from `.dev.vars.` instead of `.dev.vars`. On a profile's first deployment the script uploads missing secret values from that file when it exists, so put the secrets the profile's vault mode requires there: in `kms` mode that is `SECRET_VAULT_KMS_URL` and `SECRET_VAULT_KMS_TOKEN`, in `local` mode it is `SECRET_VAULT_LOCAL_KEK_V1`. > **Keep overlays out of version control** > > Overlays are the only place account identifiers, domains, and service > bindings should live. They are gitignored by pattern; never force-add one and > never copy its contents into `wrangler.jsonc`. ## Next Sign in to the console and follow the [Quickstart](https://docs.appaigateway.com/quickstart): add a provider key, create an app, and send the first request. When you are ready for your own hostname, continue with [Custom domain and rate limiting](https://docs.appaigateway.com/self-hosting/custom-domain). --- # Configuration Source: https://docs.appaigateway.com/self-hosting/configuration/ Configure a self-hosted gateway, including secrets, sign-in, hostnames and maintenance. Hosted users manage their apps and providers in the console; none of these settings are needed. This page configures a gateway you run yourself. A self-hosted gateway is configured through Worker variables and secrets. A variable is plain text you can read back in the dashboard. A secret is write-only: once set, only its name is visible. Provider keys are not configured here at all. You add them in the console, and the gateway encrypts them with the vault described below. ## Where a setting lives | Place | What it holds | When you use it | | --- | --- | --- | | The Deploy to Cloudflare form | Every secret name in `.dev.vars.example`, masked, and every entry in the `vars` block of `wrangler.jsonc`, in clear text with its default. | The one-click deployment. | | The Worker's **Settings → Variables** in the Cloudflare dashboard | Any variable or secret, after deployment. | Changing a setting on a deployed gateway without a redeploy from a checkout. | | `.dev.vars` in your checkout | Secrets. `pnpm run secrets:upload` sends them to the Worker; `pnpm run dev` reads them locally. | Wrangler deployments and local development. | | The `vars` block of a profile overlay | Plain variables for one deployment. | Anything a [deployment profile](https://docs.appaigateway.com/self-hosting/deploy-with-wrangler) changes. | One rule keeps those places consistent: a setting is listed in exactly one of `.dev.vars.example`, where it is a secret, or the `vars` block of `wrangler.jsonc`, where it is a plain value. The deploy form masks every name from the first file and prints every value from the second, so a setting that appears in both is asked for twice, once behind asterisks. Set a secret with Wrangler's hidden prompt or from stdin, never as a command argument: ```sh pnpm exec wrangler secret put SECRET_VAULT_LOCAL_KEK_V2 ``` Wrangler and button deployments can overwrite dashboard edits to plain variables. Keep persistent changes in the configuration used to deploy, or configure [Wrangler's `keep_vars`](https://developers.cloudflare.com/workers/wrangler/configuration/). CLI updates already use `--keep-vars` to preserve additional dashboard variables; values explicitly supplied by the deployment configuration still take precedence. Secrets are managed separately from plain variables. ## The vault Provider keys are encrypted at rest. `SECRET_VAULT_MODE` chooses how, and the choice is explicit: nothing is inferred from which secrets happen to exist, and a missing or mixed configuration is a hard error rather than a quiet fallback. | `SECRET_VAULT_MODE` | Requires | Use it when | | --- | --- | --- | | `local` (default) | `SECRET_VAULT_LOCAL_KEK_V1`; optionally `SECRET_VAULT_LOCAL_KEK_V` and `SECRET_VAULT_LOCAL_KEK_CURRENT_VERSION` | Self-hosting. AES-256-GCM inside the Worker under a key you hold. No second service. | | `kms` | `SECRET_VAULT_KMS_URL`, `SECRET_VAULT_KMS_TOKEN` | Hardened deployments. Envelope encryption through a [cf-kms](https://github.com/maxceem/cf-kms) Worker in a separate Cloudflare account, so the root key never enters this account and every decrypt is audited and revocable. | `SECRET_VAULT_LOCAL_KEK_V1` is a secret. Make one with: ```sh openssl rand -base64 32 ``` `SECRET_VAULT_LOCAL_KEK_CURRENT_VERSION` is a plain variable naming which `SECRET_VAULT_LOCAL_KEK_V` new writes use. Older versions stay accepted for reading, which is what makes rotation possible; see [Operations](https://docs.appaigateway.com/self-hosting/operations). A variable from the other mode is an error, not something to ignore: `SECRET_VAULT_LOCAL_KEK_V1` present in `kms` mode, or `SECRET_VAULT_KMS_URL` present in `local` mode, marks the vault as misconfigured. `GET /v1/healthz` reports the result in a non-secret `vault` field, `"ok"` or `"misconfigured"`, and the Worker log names the offending variable without printing its value. ### Running kms mode Deploy a cf-kms instance dedicated to this gateway in your security account, on a custom domain, with its own `KEK_V1` and a `CALLERS` entry of: ```json { "name": "app-ai-gateway", "requiredContext": { "service": "app-ai-gateway" } } ``` Then set `SECRET_VAULT_MODE=kms` on this Worker, remove `SECRET_VAULT_LOCAL_KEK_CURRENT_VERSION`, and add the URL and the caller token as secrets. Encrypted blobs are self-describing by prefix, so switching modes on a gateway that already holds provider keys is a decrypt-and-re-encrypt migration, and a mismatch fails loudly rather than silently. > **Back the vault key up** > > In `local` mode, losing every `SECRET_VAULT_LOCAL_KEK_V` makes the stored > provider keys permanently unreadable. Keep a copy outside Cloudflare. ## Generated secrets | Name | Purpose | | --- | --- | | `JWT_SECRET` | Signs the short-lived tokens the gateway issues to your apps. | | `BETTER_AUTH_SECRET` | Signs console sessions and management keys. | Both are created on the first deployment, by the deploy script or by the one-click flow, and kept unchanged afterwards. For local development, `pnpm run secrets:setup-local` generates them into `.dev.vars`. You never supply a value for either. ## Sign-in ### `ALLOWED_REGISTRATION_EMAILS` Plain variable, empty by default. The email addresses that may create an account in the console, separated by commas and matched without regard to case. Each listed person who registers gets an account of their own, with no access to anyone else's. Nobody else can register, and an empty list means nobody can. `*` lets anyone register. The list only decides who may register. Removing an address does not remove that person or stop them signing in. It does not apply to claiming the account `agw deployment setup` creates. Only the CLI holding that account's key can start a claim, so `agw account claim` works with any email. ### `CLI_BOOTSTRAP_TOKEN_DIGEST` Secret, set by `agw deployment setup` when it deploys the Worker. It is the SHA-256 of the token the CLI then creates its account with, so that account is the CLI's however soon anyone else finds the address. Without it, nothing can create an account without signing in. You never set it yourself. ### `DEPLOYMENT_ID` An immutable public UUID stored as a plain Worker binding. The deployment helpers generate it automatically and reuse the deployed value on updates and domain changes. The CLI compares this identity with Cloudflare settings before adopting an existing Worker. Do not upload it as a secret. You normally do not need to set it. An explicit value in the build environment or Wrangler `vars` is supported for initial setup, but must match the existing identity on updates, and must be 8 to 88 letters, digits, underscores or hyphens. Local setup stores its development value in ignored `.dev.vars`; `pnpm run deploy` uses that value only when no deployed or explicitly configured identity exists. ### `CLI_CONSOLE_ORIGIN` Optional secure first-party origin for browser handoffs. It must serve the same Worker: both the console, which renders the approval page the CLI links to, and the `/v1/cli/browser/` routes that page calls. HTTPS is required outside local development. Leave it unset when the API and console share an origin, unless you want MCP clients to connect with OAuth: that needs it set to the console's one canonical address; see [MCP clients](#mcp-clients). ### `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` Secrets. Both must be set for the console to offer sign-in with Google; with either missing the button does not appear. Create an OAuth client in Google Cloud with the authorised redirect URI: ```text https://YOUR-HOST/v1/auth/callback/google ``` Google follows the same registration rule as email and password: only a listed email can create an account. A pending claim can create the person who owns its existing account through the scoped Google flow, with any email. Signing in with Google never joins a sign-in that already exists for the same email address. If you registered that address with a password, keep using the password; Google sign-in is refused for it rather than opening the same account. ### `OAUTH_RELAY_URL` Plain variable, unset by default. It points at an OAuth callback relay for local instances whose hostname changes, so it cannot be registered with Google. A deployment that owns a stable hostname leaves it unset. See [Local development](https://docs.appaigateway.com/self-hosting/local-development). ## Email verification Unset by default, and then nothing is verified: every sign-in is trusted with the address it gave, and `ALLOWED_REGISTRATION_EMAILS` is the only check on who registers. Anyone who knows a listed address could register it before its owner does. They would get an account of their own, with no access to anyone else's, and the owner could not register that address until the sign-in was removed. To verify addresses, give the gateway a way to send email: an SMTP server from any mail provider, or Cloudflare Email Service. Configuring either one turns verification on: - A sign-up with a password is sent a link that verifies its address. It lasts 24 hours, and the console offers to send a new one. A Google sign-in is verified by Google and needs none. - Until the owner of an account has verified their address, its apps answer every request with [`email_unverified`](https://docs.appaigateway.com/integrate/errors). Everything else works, so apps, providers and keys can all be set up first. - Claiming the account `agw deployment setup` creates waits for a verified address too: the approval page asks for it after you create your sign-in. - Accounts that existed before you turned verification on are unaffected, and keep serving requests whoever owns them. Both ways need a domain you own, with the records your mail provider asks for (SPF and DKIM), or verification emails land in spam. A `workers.dev` address cannot send email. ### SMTP: `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD` Works on the Workers Free plan, with any provider that offers SMTP sending. Set three plain variables and one secret, plus `EMAIL_FROM`. Setting any one of them counts as choosing SMTP, so one left out is reported rather than ignored: | Name | Kind | Value | | --- | --- | --- | | `SMTP_HOST` | Variable | Your provider's SMTP server, for example `smtp.example.com`. | | `SMTP_PORT` | Variable | `465` (the default) for TLS from the start, or the port your provider gives for STARTTLS, usually `587`. Port 25 is not available to Workers. | | `SMTP_USERNAME` | Variable | The account the gateway signs in as, usually the sending address. | | `SMTP_PASSWORD` | Secret | Its password, or an app password where your provider has them. | | `EMAIL_FROM` | Variable | The address emails come from. Bounces come back to it, so use a mailbox you check. | | `EMAIL_REPLY_TO` | Variable | Optional. Where replies go instead, such as a support inbox. | ```sh pnpm exec wrangler secret put SMTP_PASSWORD ``` The gateway only signs in over an encrypted connection: on a port other than 465 it refuses to send the password if the server does not offer STARTTLS. ### Cloudflare Email Service: `EMAIL` Needs the Workers Paid plan to send to anyone but yourself. 1. Onboard your domain to [Email Sending](https://developers.cloudflare.com/email-service/), in the dashboard or with `pnpm exec wrangler email sending enable example.com`. 2. Add a `send_email` binding named `EMAIL` and set `EMAIL_FROM` to an address on that domain, in your profile overlay: ```jsonc { "send_email": [{ "name": "EMAIL" }], "vars": { "EMAIL_FROM": "gateway@example.com" } } ``` ### `EMAIL_REPLY_TO` Plain variable, unset by default, for either way of sending. Set it to send replies somewhere other than `EMAIL_FROM`: a dedicated sending mailbox for `EMAIL_FROM`, whose password the gateway holds, and your support inbox here. Bounces still go to `EMAIL_FROM`. ### `EMAIL_SENDER` Plain variable, unset by default. With only one way to send configured, the gateway uses it. With both, set `EMAIL_SENDER` to `smtp` or `cloudflare` to choose; switching is changing this one value. `/v1/healthz` reports `"email": "ok"` once the chosen way is ready to send, `"off"` with none configured, and `"misconfigured"` when one is configured but cannot send yet: a setting missing, `EMAIL_FROM` not an address, or both ways configured without `EMAIL_SENDER`. Verification stays on while it is misconfigured, and the Worker's logs name the setting to fix when a link cannot be sent. ## Hosts ### `PUBLIC_API_URL` Plain variable, unset by default. Set it when the gateway answers on a second custom domain that your apps call, separate from the one you sign in to. It must be an origin: `https`, no path, no query. It changes what the console prints as the base URL and which routes answer on that host; see [Custom domain and rate limiting](https://docs.appaigateway.com/self-hosting/custom-domain). ## MCP clients The [MCP server](https://docs.appaigateway.com/automation/mcp) accepts a management key without extra configuration. OAuth through the console's consent page also requires a stable deployment ID and console origin. The deployment helpers generate [`DEPLOYMENT_ID`](#deployment_id) automatically; CLI deployment setup also configures the console origin for you. For Wrangler or button deployments, set [`CLI_CONSOLE_ORIGIN`](#cli_console_origin) to the one canonical address of your console — `https`, or for local development `http` on a loopback host: `localhost`, `127.0.0.1`, `[::1]` or any name under `.localhost`, such as `http://gateway.localhost:5173`. That address is the authorization server every token is issued by and bound to, so it has to be one fixed origin: if the gateway took it from each request, a deployment answering on two names would issue tokens for both, and the nightly cleanup of ended connections, which has no request, would have none. Without either setting, OAuth is off: the `/.well-known` and `/oauth` addresses answer `404`, access tokens are refused, and clients connect with management keys only. A plain `http` address on any other host is not a console origin the gateway accepts at all, so it is refused rather than leaving OAuth off; see [`CLI_CONSOLE_ORIGIN`](#cli_console_origin). ### `MCP_ALLOWED_ORIGINS` Plain variable, unset by default. The browser origins, besides the console's own, that may call the [MCP server](https://docs.appaigateway.com/automation/mcp) at `/mcp` and its OAuth endpoints under `/oauth`, comma-separated: ```jsonc title="wrangler.prod.overlay.jsonc" { "vars": { "MCP_ALLOWED_ORIGINS": "https://inspector.example.com,https://agent.example.org" } } ``` Each entry is an exact origin: `https` (or `http` on a loopback host such as `localhost`, for local development), host and port, no path. An entry that is not one is ignored and logged as a warning, and the others still apply. A listed origin receives CORS headers and its preflight is answered; a request from any other web page is refused with `403`. MCP clients that are not browsers send no origin and are served whatever this says, so most deployments leave it unset. On a deployment reachable through more than one host name, also set [`CLI_CONSOLE_ORIGIN`](#cli_console_origin): without it, every host the Worker answers on counts as the console's, and so does each host's own origin. ### `OAUTH_CIMD` Plain variable, unset by default, which leaves it on. An MCP client connecting with OAuth identifies itself with an `https` URL, the address of a Client ID Metadata Document describing it; the gateway fetches that document when the client asks to connect, within fixed bounds: `https` only, no address or `localhost` hosts, no redirects, five seconds and 64 KiB. Set it to `false` to refuse every client that identifies itself this way, so only the clients in `OAUTH_CLIENTS` can connect: ```jsonc title="wrangler.prod.overlay.jsonc" { "vars": { "OAUTH_CIMD": "false" } } ``` The authorization server's metadata says which: its `client_id_metadata_document_supported` follows this variable. ### `OAUTH_CLIENTS` Plain variable, unset by default. An advanced option: OAuth clients you register yourself, for a client that cannot publish a metadata document, as a JSON array of `clientId`, `name` and `redirectUris`: ```jsonc title="wrangler.prod.overlay.jsonc" { "vars": { "OAUTH_CLIENTS": "[{\"clientId\":\"my-tool\",\"name\":\"My tool\",\"redirectUris\":[\"http://127.0.0.1/callback\"]}]" } } ``` A registered client is a public client, with no secret. Its `name` is shown on the consent page as yours rather than the client's, so the page does not name a domain for it. Each redirect URI must be absolute, with no fragment: `https`, `http` on a host written exactly `127.0.0.1`, `[::1]` or `localhost` (any port then matches), or an app's own scheme such as `com.example.app:/callback`. Any other address must match character for character. An entry that breaks a rule is ignored and logged as a warning, and the others still apply. A registered `clientId` wins over a metadata document of the same address. ## Providers ### `PROVIDER_TTFB_TIMEOUT_SECONDS` Plain variable, default `120`. How long a provider may take to send its response headers before the gateway gives up and answers `504`. Once headers arrive the body streams for as long as the model needs; only the first byte is bounded. Reasoning models can think for a minute before that first byte, which is why the default is generous. A custom endpoint's fallback chain shares one budget across all of its targets, so the wait for response headers cannot exceed this across the chain. A primary that never answers consumes the whole budget and leaves no time for a fallback. Lowering the value shortens that wait; it does not reserve time for fallbacks. ## Nightly maintenance ### `MAINTENANCE_QUERY_BUDGET` Plain variable, default `50`. How many D1 queries the nightly maintenance cron may issue in one run: it prunes sign-in history and spent challenges, expires finished CLI authorizations, and compacts usage history into its rollups. Every D1 query is a subrequest, and a Worker invocation may issue 50 of them on the Cloudflare Free plan and 1,000 on the Workers Paid plan. The default is the Free number, so a gateway installed with one click stays inside it without being asked anything. The run stops at the budget and continues the next night, which is why exceeding it is never necessary: nothing is lost, only deferred. Raise it only if you are on the Workers Paid plan and have a backlog worth draining faster — after a long period of heavy traffic, or after restoring an old database. Values below `20` are ignored, and so is anything that is not a whole number. Set it in the dashboard under **Settings → Variables**, or in a profile overlay; it is deliberately not part of the one-click form. ## Legal links ### `TERMS_OF_SERVICE_URL` and `PRIVACY_POLICY_URL` Plain variables, unset by default. When both are set, the sign-up screen shows a consent line linking to them. With either missing, nothing is shown. Set them in a profile overlay or in the dashboard. ## Management analytics ### `MANAGEMENT_ANALYTICS` [Analytics Engine](https://developers.cloudflare.com/analytics/analytics-engine/) dataset binding, unset by default. With it bound, the gateway writes one data point for every management call — from the console, the API, the CLI or an MCP client — so you can see which of those your accounts use, and how often each is refused or fails. Unbound, nothing is recorded and nothing extra runs. Each point carries the account id, the channel, the credential type, the operation or MCP tool, how the call ended (its HTTP status, error code and, for an invalid request, the top-level field the schema refused), which known client called, such as `agw`, `curl` or `claude-code` (`other` for any client not on the gateway's list), the client's version, how long the call took, and Cloudflare's request id. It never carries an error message, a request value, a credential or an email address, and a client's name is never copied as sent. Calls your apps make through the gateway are not recorded. A call refused before sign-in still names the operation it asked for, and its reason says why: `credential_missing` (nothing sent), `credential_malformed` (something that cannot be a management key or token), `console_header_missing` (a console session cookie sent without the console), `credential_rejected` (a well-formed key or token that is expired or revoked) or `session_expired`. The first three are usually a client's mistake; the last two a credential that went stale. The client is answered `401 auth_required` whichever it is. Every refusal and failure is also logged in the Worker's logs with the same request id, as `ray`, so a refused call you find in the dataset leads to its log line. Add the binding in your profile overlay: ```jsonc { "analytics_engine_datasets": [ { "binding": "MANAGEMENT_ANALYTICS", "dataset": "agw_management" } ] } ``` Query it with the [SQL API](https://developers.cloudflare.com/analytics/analytics-engine/sql-api/). The blobs are, in order: channel, operation, tool, kind, outcome, code, reason, field, issue, credential, identity, client, client version, request id. The doubles are the HTTP status and the duration in milliseconds, and the index is the account id. For example, the refusal rate per channel over the last week: ```sql SELECT blob1 AS channel, SUM(_sample_interval) AS calls, SUM(IF(blob5 = 'refused', _sample_interval, 0)) AS refused FROM agw_management WHERE timestamp > NOW() - INTERVAL '7' DAY GROUP BY channel ``` ## Sign-up analytics Optional and unset by default. Bind a Worker of your own as `ANALYTICS` to be told each time an account is created, for your own sign-up reporting. With nothing bound, the gateway reads nothing for it and sends nothing. ```jsonc title="wrangler..overlay.jsonc" { "services": [ { "binding": "ANALYTICS", "service": "your-analytics-worker", "entrypoint": "AnalyticsHook" } ] } ``` Your Worker exports that entrypoint with one method, `accountCreated(event)`. The event below is everything it receives, defined in `src/analytics/hook.ts`. It never includes a key, a password, an email address, a name or the request. | Field | What it is | | --- | --- | | `accountId` | The new account's random ID. | | `userId` | The random ID of the person who created it. | | `kind` | `created`, or `claimed` when a person takes over an account an agent set up. | | `occurredAt` | When it happened. | | `country` | The two-letter country Cloudflare placed the sign-up in, or `null`. | | `firstTouch` | Where the visitor first came from, or `null`. | `firstTouch` comes from a `first_touch` cookie that your own website sets on a first visit, on a parent domain the console shares. The gateway reads only that cookie, only when an account is created and never when the browser sends Do Not Track (which also leaves `country` empty), and keeps only these fields, each checked and length-limited: `referrerHost`, `landingPath`, `utmSource`, `utmMedium`, `utmCampaign`, `firstSeenAt` and `visitorId`. The call runs after the account is saved. Its result is ignored and a failure is only logged, so the bound Worker cannot slow down or change a sign-up. ## Summary | Name | Kind | Default | | --- | --- | --- | | `SECRET_VAULT_MODE` | Variable | `local` | | `SECRET_VAULT_LOCAL_KEK_V1`, `SECRET_VAULT_LOCAL_KEK_V` | Secret | Required in `local` mode | | `SECRET_VAULT_LOCAL_KEK_CURRENT_VERSION` | Variable | `1` | | `SECRET_VAULT_KMS_URL`, `SECRET_VAULT_KMS_TOKEN` | Secret | Required in `kms` mode | | `JWT_SECRET`, `BETTER_AUTH_SECRET` | Secret | Generated | | `ALLOWED_REGISTRATION_EMAILS` | Variable | Empty | | `CLI_BOOTSTRAP_TOKEN_DIGEST` | Secret | Set by `agw deployment setup` | | `DEPLOYMENT_ID` | Variable | Automatically generated and preserved on updates | | `CLI_CONSOLE_ORIGIN` | Variable | Current origin | | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | Secret | Unset | | `OAUTH_RELAY_URL` | Variable | Unset | | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME` | Variable | Unset (no verification); port `465` | | `SMTP_PASSWORD` | Secret | Unset; required with `SMTP_HOST` | | `EMAIL` | `send_email` binding | Unset (no verification) | | `EMAIL_SENDER` | Variable | Unset; required when both are configured | | `EMAIL_FROM` | Variable | Unset; required to send | | `EMAIL_REPLY_TO` | Variable | Unset (replies go to `EMAIL_FROM`) | | `PUBLIC_API_URL` | Variable | Unset | | `MCP_ALLOWED_ORIGINS` | Variable | Unset | | `OAUTH_CIMD` | Variable | Unset (on) | | `OAUTH_CLIENTS` | Variable | Unset | | `PROVIDER_TTFB_TIMEOUT_SECONDS` | Variable | `120` | | `MAINTENANCE_QUERY_BUDGET` | Variable | `50` | | `TERMS_OF_SERVICE_URL`, `PRIVACY_POLICY_URL` | Variable | Unset | | `MANAGEMENT_ANALYTICS` | Analytics Engine binding | Unset (nothing recorded) | | `ANALYTICS` | Service binding | Unset | --- # Custom domain and rate limiting Source: https://docs.appaigateway.com/self-hosting/custom-domain/ Serve the gateway on your own hostname, give your apps a separate API host, and see the limits that bound abuse. A `workers.dev` address works, but it belongs to no zone of yours, so there is nowhere to attach a WAF rule. Serving the gateway on a domain you own in Cloudflare gives you one, and lets you refuse a flood before it ever starts a Worker. The gateway's own rate limits, further down, run either way. Everything on this page is zone configuration and a deployment profile; nothing here changes code. ## Serve on a custom domain Add the route to a [deployment profile](https://docs.appaigateway.com/self-hosting/deploy-with-wrangler) and turn the `workers.dev` address off: ```jsonc title="wrangler.prod.overlay.jsonc" { "workers_dev": false, "routes": [{ "pattern": "console.example.com", "custom_domain": true }] } ``` ```sh pnpm run deploy --profile prod ``` The domain must be a zone in the same Cloudflare account. With `custom_domain: true`, Cloudflare creates the DNS record and the certificate for you. ## A separate host for your apps One Worker can answer on two custom domains: the one you sign in to, and one your apps call. Add both routes to the overlay and name the second one in `vars`: ```jsonc title="wrangler.prod.overlay.jsonc" { "workers_dev": false, "routes": [ { "pattern": "console.example.com", "custom_domain": true }, { "pattern": "api.example.com", "custom_domain": true } ], "vars": { "PUBLIC_API_URL": "https://api.example.com" } } ``` `PUBLIC_API_URL` is an origin: `https`, no path, no query. A value with a path or a query is rejected rather than used, because it would silently produce base URLs no client can call. Setting it changes two things: - **The console advertises the API host.** Wherever the console shows a base URL, it prints `https://api.example.com`, so new apps are configured against the API host from the start. - **The API host refuses the console surface.** On `api.example.com`, `/v1/auth/*`, `/v1/console/*` and the MCP endpoint `/mcp` answer `404`. App routes, `/v1/healthz` and `/v1/admin/*` are served normally. No session cookie can be issued for a host that has no sign-in, so a management call arriving there is a management-key call by construction. The console host is unchanged and keeps serving the app routes too, so clients configured before the second host existed keep working. Leaving `PUBLIC_API_URL` unset is the default and changes nothing: the console builds client URLs from its own origin. ### Keep the API host to the API Static assets are served before the Worker runs, so the console's files still load at `https://api.example.com/`. An API host should answer nothing but the API. Refuse everything outside `/v1/` with a zone [WAF custom rule](https://developers.cloudflare.com/waf/custom-rules/), action **Block**, matching: ```txt (http.host eq "api.example.com" and not starts_with(http.request.uri.path, "/v1/")) ``` Unknown paths under `/v1/` still reach the Worker and get its JSON `404`. On the Free plan the blocked request receives Cloudflare's own `403` page. On Pro and above, give the Block action a custom JSON response with status `404` and the Worker's own body, so every miss on that host looks the same: ```json {"error":{"code":"invalid_request","message":"Route not found"}} ``` Do not reach for `run_worker_first` on the whole site instead. That would put every asset request through the Worker and bill for it. ## Rate limiting The gateway throttles its own authentication endpoints, on every deployment — including a one-click `workers.dev` install that has no zone to attach a rule to. The counters live in a Durable Object rather than in memory, so one count holds across every instance and location the Worker runs in. | Endpoint | Limit | Counted by | | --- | --- | --- | | `POST /v1/auth/sign-in/email` | 10 per minute | client IP | | `POST /v1/auth/sign-in/email` | 10 per 10 minutes | the email address in the body | | `POST /v1/apps/{app}/auth/challenge` | 60 per minute | app and client IP | | `POST /v1/apps/{app}/auth/register` | 60 per minute | app and client IP | | `POST /v1/apps/{app}/auth/token` | 60 per minute | app and client IP | Sign-in is counted twice because the two counts refuse different things. The count per address stops one client hammering the endpoint. The count per email address stops a spread of addresses working through one password, which no per-address count can see. Ten attempts per ten minutes is far more than you spend when you know your own password, and nowhere near enough to guess one. The three application routes are unauthenticated by design, so each is counted per app and per address, on a counter of its own. Sixty a minute is far above real use — a gateway token lasts an hour, so sixty exchanges a minute from one address speaks for thousands of devices — and still bounds a flood. Every refusal answers `429` with the code `rate_limited`, a `Retry-After` header, and a body naming the limit, its window, and when it reopens. Only `CF-Connecting-IP` is read. Cloudflare writes it on every inbound request, so a caller cannot choose its own counter; `X-Forwarded-For` can be set by anyone and is ignored. A Worker reached directly, with no Cloudflare in front of it, sees no such header and counts every caller as one. ### Optional: refuse at the edge as well Nothing in this section is required, and skipping it leaves the limits above in force. What it adds is [Cloudflare rate limiting rules](https://developers.cloudflare.com/waf/rate-limiting-rules/) in front of the Worker: a refused request never starts one, so it costs you no Worker invocation at all. Rules need a zone, which means the custom domain set up above. Create three rules, each counting by client IP. 1. **Console sign-in**, 10 requests per minute, block for 10 minutes. This is the password endpoint and the one route where guessing pays. ```txt http.request.uri.path eq "/v1/auth/sign-in/email" ``` 2. **Account creation and password reset**, 5 requests per minute. These are the routes the gateway does not count for itself. ```txt http.request.uri.path matches "^/v1/auth/(sign-up|forget-password|reset-password)" ``` 3. **App authentication**, 60 requests per minute, which mirrors the gateway's own count for these routes at the edge. ```txt http.request.uri.path matches "^/v1/apps/[^/]+/auth/(challenge|token|register)$" ``` The rules match on the path alone, so they cover every host in the zone. A separate API host is included with no second set of rules. #### On the Free and Pro plans Rate limiting rules exist on every Cloudflare plan, but not in equal amounts. If you add them at all, adapt rather than skip. - The Free plan allows **one** rule, with a fixed ten-second counting period and a ten-second block. Spend it on sign-in, at 2 requests per ten seconds. - The `matches` operator needs a Business plan or above. On Free and Pro, write rules 2 and 3 without a regular expression: ```txt http.request.uri.path in {"/v1/auth/sign-up/email" "/v1/auth/forget-password" "/v1/auth/reset-password"} ``` ```txt starts_with(http.request.uri.path, "/v1/apps/") and (ends_with(http.request.uri.path, "/auth/challenge") or ends_with(http.request.uri.path, "/auth/token") or ends_with(http.request.uri.path, "/auth/register")) ``` ## Checklist After the profile is deployed and the zone is configured: ```sh curl https://api.example.com/v1/healthz curl -i https://api.example.com/v1/auth/sign-in/email curl -i https://api.example.com/ ``` The first answers `"vault": "ok"`. The second answers `404` from the Worker. The third is blocked by the WAF rule. Re-check the rules after any zone change; they live in the dashboard, not in the repository. --- # Updating Source: https://docs.appaigateway.com/self-hosting/updating/ Pick up new releases, understand what a deployment changes, and handle database migrations safely. How you update depends on how you deployed. Every route preserves your secrets, applies any new database migrations, and redeploys the Worker together with its console. ## CLI deployments A gateway installed with `agw deployment setup` is updated by the CLI itself: ```sh npm install -g @maxceem/agw agw deployment update --dry-run agw deployment update ``` Each CLI version installs exactly one gateway build, so picking up a new release means installing the newer CLI. It downloads that build once from the project's GitHub release, checks it against a SHA-256 built into the CLI, and caches it under `~/.cache/agw`; `--release-archive ` uses a copy you downloaded yourself instead, for a machine with no route to GitHub. The dry run reports the version it would move to and changes nothing. The update applies any new migrations and then deploys. ## Button deployments The one-click flow forked the repository into your GitHub account and connected the fork to Workers Builds. Every push to the connected branch triggers a build and a deploy. The deploy command Workers Builds runs is the repository's `deploy` script, the same one a checkout uses, so migrations are applied as part of it. You can see and change it under the Worker's **Settings → Build**. To pick up a new release, bring the upstream changes into your fork and push: ```sh git clone https://github.com/YOUR-ACCOUNT/app-ai-gateway cd app-ai-gateway git remote add upstream https://github.com/maxceem/app-ai-gateway git fetch upstream git merge upstream/main git push ``` Watch the build under the Worker's **Deployments** in the Cloudflare dashboard. When it finishes, check the health endpoint as after the first deployment: ```sh curl https://YOUR-WORKER.workers.dev/v1/healthz ``` Plain variables changed only in **Settings → Variables** can be overwritten by the next deploy. Preserve them in the deployment configuration, or use [Wrangler's `keep_vars`](https://developers.cloudflare.com/workers/wrangler/configuration/) when you intentionally manage them in the dashboard. Existing secrets are kept. ## Wrangler deployments Pull the release into your checkout and deploy again: ```sh git pull pnpm install pnpm run deploy ``` Add `--profile ` when the deployment uses a [profile](https://docs.appaigateway.com/self-hosting/deploy-with-wrangler). To see what a release would change before deploying it, run the dry run first: ```sh pnpm run deploy:dry-run --profile prod ``` The deploy script checks that the vault secret still exists, leaves `JWT_SECRET` and `BETTER_AUTH_SECRET` as they are, applies migrations, and deploys. App and provider configuration stays in the database. Plain Worker variables come from the deployment configuration, so preserve dashboard-only changes there before deploying. ## Database migrations Migrations live in the `migrations` directory and are applied by the deploy script through the `DB` binding, so a deployment does not depend on a particular database name. You can also apply them on their own: ```sh pnpm run db:migrate pnpm run db:migrate --profile prod ``` Applying is idempotent. A migration that has already run is skipped, so running the command twice is harmless. > **A Worker rollback does not roll back the database** > > Cloudflare can roll a Worker back to a previous version, but the database > keeps every migration that has been applied. Rolling back to a version that > predates a migration leaves the code and the schema out of step. Review the > migrations a release brings before deploying it to a gateway your apps > depend on, and never edit a migration that has been applied. A safe routine for a release that includes migrations: 1. Read the new files under `migrations` and confirm they only add what the release needs. 2. Deploy to a second gateway first if you run one, for example a staging profile with its own database. 3. Deploy to the gateway your apps use, then check `/v1/healthz` and send one request through an app. ## Updating from v0.3.0 or earlier v0.4.0 changed the database layout and cannot update an existing v0.3.0 or earlier gateway in place. `agw deployment update` refuses such an update and changes nothing. A button or Wrangler deployment has no such guard, so do not point one at v0.4.0. Deploy v0.4.0 as a fresh gateway instead, and recreate your apps, keys and provider credentials there. If you need help moving data from an existing installation, [open an issue on GitHub](https://github.com/maxceem/app-ai-gateway/issues). ## For contributors Change the schema definition, run `pnpm run db:generate` to produce the migration file, and commit both the schema change and the generated migration together. Never modify a migration that a deployment has already applied; add a new one instead. ## Checking a checkout Before deploying a change of your own, run the same checks the project runs: ```sh pnpm run verify pnpm run deploy:dry-run ``` `verify` type-checks every project, checks the generated OpenAPI document for drift, and runs the Worker, console, CLI and script tests. The dry run builds the console and resolves the Wrangler configuration without deploying anything. --- # Operations Source: https://docs.appaigateway.com/self-hosting/operations/ Health, logs, key rotation, recovery, retention, and what to watch on a gateway you run yourself. This page is for a gateway you run yourself. For hosted apps, start with [Usage](https://docs.appaigateway.com/applications/usage), [Auth & Errors](https://docs.appaigateway.com/applications/auth-events) and [client errors](https://docs.appaigateway.com/integrate/errors); you do not need Cloudflare access. ## Health `GET /v1/healthz` answers without authentication: ```json { "ok": true, "service": "app-ai-gateway", "vault": "ok" } ``` The `vault` field is the one to monitor. `"misconfigured"` means the `SECRET_VAULT_*` configuration is incomplete, contradictory, or invalid, and every request that needs a provider key answers `provider_unavailable`. The structured `secret_vault_misconfigured` log line names the offending variable without printing its value. Check this field after any change to a vault variable or secret. ## Logs and traces The Worker ships with Cloudflare observability enabled: every invocation is logged and one percent of them is traced. Open the Worker's **Logs** in the dashboard, or stream them: ```sh pnpm exec wrangler tail ``` Every rejected request emits one structured `gateway_error` line carrying the error `code`, its granular `reason`, the status, the path and method, the app, and the `X-App-Version` the client sent. Nothing else: no request body, no token, and no other header, because these lines are emitted for authentication failures whose payload is a credential. Statuses below 500 log at `warn`, 500 and above at `error`. A `usage_unresolved_cost` line at `error` level means a request was served but its cost could not be read from the provider's response. Proxied responses also carry a `Server-Timing` header with three measurements: `auth` for authentication, `limiter` for limit evaluation, and `provider_ttfb` for the provider's time to first byte. It is useful when a client reports slowness and you want to know which side of the gateway it is on. ## Rotate the vault key In `local` mode, add `SECRET_VAULT_LOCAL_KEK_V2` as a Worker secret and set `SECRET_VAULT_LOCAL_KEK_CURRENT_VERSION` to `2`: ```sh openssl rand -base64 32 # the new key, kept safe like the first pnpm exec wrangler secret put SECRET_VAULT_LOCAL_KEK_V2 # paste it at the hidden prompt ``` Older versions stay accepted for decryption, so existing provider keys keep working and new writes use the new key. There is no bulk re-encryption script, so keep the old version until every provider key and provider-gateway token has been rotated through the console, which re-encrypts each under the current version. In `kms` mode, key rotation is a cf-kms operation and does not touch this Worker. Use Wrangler's hidden prompt or stdin so no secret value enters shell history. ## Recover access If nobody can sign in, `pnpm recover-access` resets a password directly in the database. It shells out to `wrangler d1 execute`, prints neither the password nor its hash, and refuses to choose a database implicitly: you name `--local` or `--remote` yourself. ```sh pnpm recover-access -- --email owner@example.com --password-stdin --remote < /secure/password-file ``` Add `--profile ` to target a deployment profile. The script can also promote an existing member to owner; run it with `--help` for the flags. Try the procedure with `--local` first. Running it against the remote database is an explicit, deliberate step. ## Retention A single cron trigger runs nightly maintenance at 03:17 UTC and keeps the database bounded: | Data | Rule | | --- | --- | | Authentication events | Deleted after 90 days. | | App Attest challenges | Deleted once their five-minute window has passed. An expired challenge cannot be redeemed, so nothing a client sees changes. | | Usage events | Raw per-request rows older than 90 days are summed into daily totals per app, model, provider and status, then deleted. Daily totals older than 400 days are folded into monthly totals. | | CLI authorizations | A finished browser handoff is deleted, and the short-lived copy of a one-time CLI response is cleared once its recovery window has passed. | No usage total ever disappears. The **Usage** tab keeps answering for any period; what it loses past 90 days is the per-request and per-user detail. The nightly run is sized for the D1 limits of Cloudflare's Free plan and clears about 55,000 events a night, which comfortably exceeds what a million requests a month produces. A backlog larger than one night is continued the next one; on the Workers Paid plan, [`MAINTENANCE_QUERY_BUDGET`](https://docs.appaigateway.com/self-hosting/configuration) lets one run do more. ## Reprice stored usage If a provider's prices change or you add a custom price after the fact, `POST /v1/admin/apps/{app}/usage/reprice` recomputes the stored cost for one provider type, model, and month. Run it with `apply: false` first: the preview reports how many events would be rewritten and how many cannot be priced. `apply: true` is strict and writes nothing if any matched event has no price, so a month is never left half-repriced. Events whose cost the provider itself reported are never touched; that figure is what you were charged. The request shape is in the [API reference](https://docs.appaigateway.com/api), and management keys for calling it are covered in [API](https://docs.appaigateway.com/automation/api). A reprice moves each affected month's spend totals in the same write, and the app's limit counters read the new totals within about ten seconds, however many users the month touched. ## Request volume A self-hosted gateway relies on your configured app limits and infrastructure capacity to bound request volume. It also bounds what a request may do: an app reaches only the providers, paths and models it is configured for, only priced models proxy at all, and you can block a user or disable an app or a provider at any time. Without per-user or per-app limits, a compromised app key or a looping client can keep sending requests until you intervene. Disabling or deleting a provider can take up to 60 seconds to reach another warm Worker isolate because provider rows are cached; for an immediate emergency stop, revoke the key in the provider account too. Put the ceiling where the money is: - Set spend caps and alerts in each provider account where available. - Watch the request and cost trends on the [Usage](https://docs.appaigateway.com/applications/usage) tab, and set per-user and per-app limits on each app. - When something looks wrong, block the user on the [Users](https://docs.appaigateway.com/applications/users) tab or disable the app while you look. > **Aborted streams are unmeasured spend** > > A client that disconnects before the last chunk of a streamed response takes > the provider's usage figures with it. Such events show as **unresolved** in > the console and emit `usage_unresolved_cost`. A background rate is normal; a > rising one from a single app or user is worth investigating. ## Read replication The current gateway reads app configuration, provider rows, credentials and account state from the D1 primary. Bounded Worker caches provide the documented propagation windows. Enabling D1 read replication does not make these reads use replicas; the gateway does not opt them into replica sessions. ## Routine verification After a deployment, a configuration change, or a Cloudflare zone change: ```sh curl https://YOUR-HOST/v1/healthz ``` Before deploying a change of your own from a checkout: ```sh pnpm run verify pnpm run deploy:dry-run pnpm run startup:check ``` `verify` runs the type checks, the OpenAPI drift check, and the Worker, console, CLI and script tests. The dry run resolves the configuration and builds the console without deploying. `startup:check` confirms the Worker starts within Cloudflare's startup limit. --- # Local development Source: https://docs.appaigateway.com/self-hosting/local-development/ Run the gateway and its console on your machine, with hot reloading and optional Google sign-in. A local gateway is the same Worker and the same console, run by Vite on one origin with a local database. Use it to try a change before deploying it, or to develop against the gateway without touching the one your apps use. ## Requirements - Node.js 22.19 or newer. - The pnpm version declared in the root `package.json`. - Wrangler 4, which `pnpm install` brings with it. - `openssl`, to generate the vault key. No Cloudflare account is needed to run locally. The database, the vault and the other resources run inside Wrangler's local runtime. ## Start it ```sh pnpm install cp .dev.vars.example .dev.vars openssl rand -base64 32 # paste the output into SECRET_VAULT_LOCAL_KEK_V1 in .dev.vars pnpm run secrets:setup-local pnpm run db:migrate:local pnpm run dev ``` Each step does one thing: | Command | What it does | | --- | --- | | `cp .dev.vars.example .dev.vars` | Creates the gitignored secrets file. Locally it only needs a vault key; any value from `openssl rand -base64 32` will do. | | `pnpm run secrets:setup-local` | Generates signing secrets and a stable local `DEPLOYMENT_ID` into `.dev.vars` and leaves existing values alone. Deployed gateways get these from the deploy script; locally this script stands in for it. | | `pnpm run db:migrate:local` | Applies the migrations to the local database under `.wrangler/`. Run it again after pulling a release that adds one. | | `pnpm run dev` | Serves the console and the Worker together. | Open `http://localhost:5173`. Vite serves the console with hot reloading and runs the Worker beside it on the same origin, so an edit to either is live without a rebuild. Local development lets any email register, so you can create as many test accounts as you need. To test the CLI path end to end, deploy a gateway of your own with `agw deployment setup`. The local gateway calls real providers when you make inference requests, so use credentials with an appropriate spend cap. `.dev.vars` is read by `pnpm run dev` and also by `pnpm run secrets:upload`, so the same file can feed a deployment. If you would rather keep the two apart, give the deployment a [profile](https://docs.appaigateway.com/self-hosting/deploy-with-wrangler), which reads `.dev.vars.` instead. ## Check a change ```sh pnpm run check pnpm run test pnpm run verify ``` `check` type-checks every project and detects drift in the generated OpenAPI document; it takes a few seconds, so run it after every edit. `test` runs the Worker, console, CLI and script tests. `verify` runs both checks and tests and is what to run before a commit or hand-off; you do not need to run all three commands in sequence. ## Google sign-in on a local gateway Google sign-in works locally once `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` are in `.dev.vars`. Google requires an exact OAuth callback URL, so for a gateway that always runs on `http://localhost:5173` register: ```text http://localhost:5173/v1/auth/callback/google ``` ### Changing local hosts A local instance answers on whatever hostname and port it was started on, and Google accepts no wildcard redirect URIs. For hosts that change, set the plain variable `OAUTH_RELAY_URL` in `.dev.vars` to the origin of an [OAuth callback relay](https://github.com/maxceem/cf-oauth-relay), a small, secret-free Worker deployed once and shared by every local instance: ```text OAUTH_RELAY_URL=https://dev-oauth.example.com ``` With it set, the gateway asks Google to redirect to `/callback/google` and sends the browser through the relay, which forwards Google's response back to the local instance that started the sign-in. The code exchange, the `state` check and the session all stay in that instance. The relay stores nothing and holds no secret. Give the relay a **separate development Google OAuth client** whose only authorised redirect URI is `/callback/google`. Keep its consent screen in Testing with the developers as test users, and leave the production client alone. Production credentials then never leave production, and the shared relay callback is not a valid redirect for them. Deployments that own a stable hostname leave `OAUTH_RELAY_URL` unset, which is the default. The gateway then registers and uses its own `/v1/auth/callback/google`. ## The documentation site The docs are a separate static site and are never bundled into the Worker: ```sh pnpm run docs:dev pnpm run docs:build ``` `docs:build` is left out of `verify` because it is slow. Run it when you change anything under `docs/`.