Docs/Timebase Components/Configuring Gage LLM providers and models
PulseHow-to

Configuring Gage LLM providers and models

Register an LLM provider in Pulse Gage and choose the models the Timebase AI assistants can use.

Updated 12 Aug 2026Timebase Docs

Overview#

Gage is Pulse's central LLM gateway. Atlas, Historian, and Collector do not declare models of their own; they proxy all AI requests through Pulse and read the model catalogue from it. This document covers the complete Gage configuration - registering LLM providers, defining the logical models they expose, setting quotas and allowlists, and interpreting usage logs. It is for Pulse administrators who configure the AI infrastructure the whole product uses.

Getting the assistant working for the first time? Quick Start: Turn On the AI Assistant walks the shortest path through Pulse and the component together, and names each error you might see. Come back here for the full reference.

Prerequisites#

  • The pulse:administrator role. Gage in Pulse is an administrator capability throughout: every /app/gage/* read is administrator-gated, including the providers catalogue, provider detail, the client and user policies, and the usage log. A non-administrator receives a 403 rather than a filtered view, and the Ask Gage assistant does not render for them inside Pulse at all.

    This is deliberately stricter than /app/clients and /app/users, whose reads any signed-in operator can make. The usage log spans every caller in the install, and the provider records are deployment configuration carrying credentials.

  • An OAuth client with administrator privileges to perform write operations.

  • At least one LLM API key (or equivalent authentication for a wire-compatible backend you control).


The Wire model#

A wire is the protocol a provider speaks - one of exactly two request/response shapes the Gage proxy understands. The wire drives three things: the upstream authentication scheme, the relay behaviours, and the canonical upstream path when none is configured. The two wires are:

Wire Name Canonical path Upstream auth Relay behaviours
0 Anthropic /v1/messages x-api-key header + Anthropic version header Injects missing max_tokens (default 4096); corrects extended-thinking request shape for pre-adaptive-thinking models
1 OpenAI /v1/chat/completions Authorization: Bearer <key> No modifications beyond model rewrite

Branding (the display name "Anthropic", "OpenAI", "Google Gemini", "Local Ollama") lives in the provider's DisplayName, not in the wire. Any backend that speaks OpenAI's Chat Completions format is wire 1 - including local Ollama servers and Google Gemini's OpenAI-compatibility endpoint (see worked examples below).


Provider fields#

A GageProvider carries these fields:

Field Type Default Required Notes
Id string - Yes Provider identifier. Lowercase, URL-safe, immutable once created (^[a-z0-9][a-z0-9-]*$). Part of model handles. Cannot be changed after creation.
DisplayName string - Yes Human-friendly label shown in the admin UI. Carries all branding. No restrictions; can be changed anytime.
Wire enum (0 or 1) - Yes The protocol: 0 = Anthropic Messages, 1 = OpenAI Chat Completions. Drives auth scheme and relay behaviour.
Endpoint string - Yes Base URL of the upstream provider (e.g., https://api.anthropic.com, https://generativelanguage.googleapis.com).
UpstreamPath string null No Optional path appended to Endpoint for the actual relay POST. When null/empty, the wire's canonical path applies (e.g., /v1/messages for Anthropic). Leading slash is added if missing. See section below for how this unlocks non-standard paths.
ApiKey string - Yes (on create) The provider API key or bearer token. Stored verbatim in the configuration store. Write-only: never returned by any read API; use the /set-key route to rotate. An empty key is allowed at creation and rotated later.
Enabled bool true No Whether this provider can be resolved, called, and used as a fallback. A disabled provider is completely invisible to callers. Disable instead of delete to retain configuration.
Models list [] No Embedded list of logical models this provider exposes. Each model has its own Id, DisplayName, RealModelId, FallbackEnabled, and Enabled flags.
CreatedAt datetime - (set by server) UTC timestamp when created. Read-only.
UpdatedAt datetime - (set by server) UTC timestamp when last modified. Read-only.

In the interface, Providers under Gage lists them with their wire, endpoint, state, default model and model count; Add provider opens a drawer with the id, display name and switch on Identity, the wire, endpoint, upstream path and API key on Connection, and the models on Logical models.

The Providers page: a grid with the seeded Anthropic provider, its Anthropic wire, endpoint, Enabled state, derived default model claude and one modelThe Providers page: a grid with the seeded Anthropic provider, its Anthropic wire, endpoint, Enabled state, derived default model claude and one model

The New provider drawer on its Connection tab: Wire set to OpenAI, an Endpoint of http://ollama.lab.local:11434, an Upstream Path of /v1/chat/completions, and a masked API KeyThe New provider drawer on its Connection tab: Wire set to OpenAI, an Endpoint of http://ollama.lab.local:11434, an Upstream Path of /v1/chat/completions, and a masked API Key


UpstreamPath - reachable non-standard backends#

UpstreamPath is the single most useful under-known field in Gage. It decouples the caller-facing front-door path (/api/gage/proxy/v1/chat/completions or /v1/messages) from the actual upstream relay target. This makes any wire-compatible backend served at a non-standard path reachable by configuration alone, no proxy code change required.

Worked example: local Ollama server#

You run Ollama locally on your machine at http://localhost:11434/v1/chat/completions.

Create a wire-1 (OpenAI) provider with:

{
  "Id": "ollama-local",
  "DisplayName": "Local Ollama",
  "Wire": 1,
  "Endpoint": "http://localhost:11434",
  "UpstreamPath": "/v1/chat/completions",
  "ApiKey": "",
  "Enabled": true,
  "Models": [
    {
      "Id": "mistral",
      "DisplayName": "Mistral 7B (local)",
      "RealModelId": "mistral",
      "FallbackEnabled": true,
      "Enabled": true
    }
  ]
}

A caller sends {"model": "ollama-local/mistral", ...} to /api/gage/proxy/v1/chat/completions. Gage relays to http://localhost:11434/v1/chat/completions with the model rewritten to mistral.

Worked example: Google Gemini's OpenAI compatibility endpoint#

Google offers an OpenAI-compatible endpoint. Create a wire-1 provider with:

{
  "Id": "gemini-compat",
  "DisplayName": "Google Gemini (OpenAI-compatible)",
  "Wire": 1,
  "Endpoint": "https://generativelanguage.googleapis.com",
  "UpstreamPath": "/v1beta/openai/chat/completions",
  "ApiKey": "<your-gemini-api-key>",
  "Enabled": true,
  "Models": [
    {
      "Id": "gemini-pro",
      "DisplayName": "Gemini 2.0 Flash",
      "RealModelId": "gemini-2.0-flash",
      "FallbackEnabled": true,
      "Enabled": true
    }
  ]
}

Callers send to /api/gage/proxy/v1/chat/completions with handle gemini-compat/gemini-pro. Gage relays to https://generativelanguage.googleapis.com/v1beta/openai/chat/completions with the API key in the Authorization: Bearer header and the model rewritten to gemini-2.0-flash.

In both cases, the caller is unaware of the actual upstream path. The admin changes the configuration; the caller's code is unaffected.


Logical models and handles#

A handle is the caller-facing identifier for a model: providerId/logicalModelId (e.g., anthropic-prod/fast). Both segments are lowercase, URL-safe (^[a-z0-9][a-z0-9-]*$), and immutable once created. An admin can remap the real upstream model (change RealModelId) without breaking the caller's code - they keep using the same handle.

Logical model fields#

A LogicalModel (embedded in a provider) carries:

Field Type Default Notes
Id string - The second segment of the handle. Immutable, lowercase URL-safe.
DisplayName string - Human-friendly label in the admin UI. Can change anytime.
RealModelId string - The actual model name passed to the upstream provider (e.g., claude-haiku-4-5, gpt-4o). Can be changed to remap the handle to a different upstream model.
FallbackEnabled bool true Whether this model participates in cross-provider fallback for its logical ID. See Fallback section below.
Enabled bool true Whether this model can be resolved and called. A disabled model is invisible to callers.

Handle resolution#

When a consuming app (Atlas, Historian, Collector) sends a Gage proxy call with a handle like anthropic-prod/fast, Gage resolves it as follows:

  1. Look up the provider by providerId (anthropic-prod).
  2. Look up the logical model by logicalModelId (fast) within that provider.
  3. Combine them into a GageModelResolution(provider, logicalModel) tuple.
  4. Use the provider's wire, endpoint, and upstream path to construct the relay target.
  5. Rewrite the request's model field to the logical model's RealModelId.
  6. Apply wire-specific corrections (Anthropic max_tokens injection, thinking-shape normalization).
  7. Relay to the upstream, re-evaluating per fallback attempt.

Wire-mismatch failure - critical callout#

The proxy does NO wire translation. If you create a provider with Wire: 1 (OpenAI) but the backend is actually Anthropic, or vice versa, the mismatch will not be caught when you save the provider. It surfaces only on the first actual chat call, when the upstream returns a 4xx or 5xx error because the request body is wrong-shaped.

Example: You intend to configure Claude via an OpenAI-compatible wrapper, but you set Wire: 1 (OpenAI) when the actual backend is Wire: 0 (Anthropic). When a caller sends a call with the handle, the proxy will send an OpenAI-format body ({ "messages": [...] } plus OpenAI-specific fields) to an Anthropic endpoint. The endpoint rejects it with HTTP 400. The error appears in the logs and returns to the caller; it does not appear in the provider configuration UI.

Prevention: Verify that the Wire you set matches the actual backend's native request format, not just its branding. If in doubt, check the upstream provider's own documentation.


Fallback#

When a provider call fails with a transient error (HTTP 429, 5xx, connection timeout) before any response is streamed, Gage automatically retries the same logical model on the next enabled provider of the same wire. Fallback keeps the request body valid on every attempt: the proxy forwards it verbatim (subject to wire-specific corrections), so a cross-wire redirect would send a wrong-shaped body and burn the retry.

Fallback selection algorithm#

Given a requested handle, Gage selects the ordered list of providers to attempt as follows:

  1. Start with the primary provider specified in the handle.
  2. Check: is the primary provider enabled AND is the requested logical model enabled? If not, fallback is empty; return immediately.
  3. Add the primary to the attempt list.
  4. If the primary's model has FallbackEnabled: true:
    • Iterate through ALL other providers (in storage order, which is the priority order).
    • Include each provider that is: enabled, same wire as the primary, and exposes an enabled, fallback-enabled model with the same logical ID.
    • Add them to the attempt list in the order encountered.
  5. Return the list. If it has only the primary, no fallback is available for that handle.

Storage order is the priority - there are no explicit chains. Disabling the model, the provider, or setting FallbackEnabled: false removes it from fallback.

Example:

Id Wire Enabled Model Model Enabled FallbackEnabled
anthropic-prod 0 ✓ fast ✓ ✓
anthropic-backup 0 ✓ fast ✓ ✓
openai-prod 1 ✓ fast ✓ ✓

A call to handle anthropic-prod/fast attempts in order:

  1. anthropic-prod (primary)
  2. anthropic-backup (same wire, same model enabled, fallback enabled)

It skips openai-prod because wire mismatch would invalidate the request body.


Policies and quotas#

Gage enforces rate limits and budgets through two kinds of policies: Client policies (keyed by OAuth client ID) and User policies (keyed by Pulse User GUID). When a call arrives, Gage applies whichever layer is relevant:

  • If the caller is a Pulse User (the X-Gage-Subject header parses as a GUID), Gage enforces the User layer.
  • If the caller is an opaque subject or a client_credentials token (which has no User layer), Gage enforces the Client layer.

Each policy kind has a reserved [default] record that applies to every caller in that scope when no per-entity override exists. The [default] policy cannot be deleted, and it is not empty: the Policies page shows the shipped [default] client policy with a Current session budget of 10M tokens per rolling 5 hours and a Weekly limits budget of 100M per 7 days, both set to Block, and the [default] user policy, which appears once a signed-in user has called Gage, with 1M per 5 hours and 10M per 7 days. Neither carries an allow-list, so Allowed models reads All.

The Policies page: Client policies and User policies grids, each with its default record showing the Current session and Weekly limits budgets, Block enforcement and all models allowedThe Policies page: Client policies and User policies grids, each with its default record showing the Current session and Weekly limits budgets, Block enforcement and all models allowed

Note

a busy assistant reaches the user budget sooner than you might expect, because every call carries the agent's tool descriptions as prompt tokens. When the assistant reports Rate limit or budget exceeded, open the policy, pick the metric and raise its Limit, or set a negative limit for unlimited.

Policy fields#

A GagePolicyDto carries:

Field Type Default Notes
Id string - The policy key: client ID, User GUID, or the literal string [default].
Metrics dict {} Named metrics (e.g., tokens.daily). Each metric has a Limit (number), optional Window (timespan; null = static count), and Policy (0 = Soft, 1 = Throttle, 2 = Block).
AllowedModels list of strings [] Allow-list of model handles. Empty means all models allowed (subject to the proxy's pre-flight check). Non-empty means only listed models are reachable.
CreatedAt, UpdatedAt datetime - Read-only audit timestamps.

Metrics and enforcement#

v1 meters token count only. Each metric name (e.g., tokens.daily) is a label; there is no metric-unit system yet. A metric can be:

  • Unlimited: Limit: -1 (or any negative). The metric never enforces.
  • Static count: Window: null. The metric is a cumulative counter that never resets.
  • Rolling rate: Window: <timespan> (e.g., PT1H for one hour). The metric rolls over the window; calls older than the window are dropped from the accumulated count.

When usage hits a threshold, the Policy triggers:

  • Soft (0): Records the overage but never blocks. Allows +25% overage before recording.
  • Throttle (1): Returns HTTP 429 (Too Many Requests) with a Retry-After header. Allows +10% overage before throttling.
  • Block (2): Rejects the call outright. Allows +5% overage before blocking.

In the Pulse interface, Edit policy opens the policy with three tabs: Identity, Rate & budget limits and Model allow-list. Each metric on the limits tab opens its own page with the Metric name, the Limit, the Window as a .NET TimeSpan such as 05:00:00, and the Enforcement.

A policy metric in the editor: Metric Current session, Limit 10000000, Window 05:00:00 and Enforcement Block, with Back to return to the metric listA policy metric in the editor: Metric Current session, Limit 10000000, Window 05:00:00 and Enforcement Block, with Back to return to the metric list

Setting client policies#

PUT /api/gage/client-policies/{clientId}
Authorization: Bearer <admin-token>

{
  "Metrics": {
    "tokens.daily": {
      "Limit": 1000000,
      "Window": "1.00:00:00",
      "Policy": 2
    }
  },
  "AllowedModels": ["anthropic-prod/fast", "anthropic-prod/smart"]
}

Returns 200 OK with the full policy DTO.

Setting user policies#

PUT /api/gage/user-policies/{userId}
Authorization: Bearer <admin-token>

{
  "Metrics": {
    "tokens.daily": {
      "Limit": 100000,
      "Window": "1.00:00:00",
      "Policy": 1
    }
  },
  "AllowedModels": []
}

An absent metric or AllowedModels inherits the [default]. An override with an explicit AllowedModels: [] (empty list) still inherits - only a non-empty list replaces the default.


Usage and logging#

Gage maintains a JSONL rolling usage log (default retention: 90 days). Every successful call is logged, regardless of outcome (even 429s and timeouts count).

The Usage page in Pulse reads the same log: filters by client, user, model, status and time, totals for the filtered set, and rollups per logical model and per calling client above the call grid.

The Usage page: filters for client, user, model, status and a time range, then Totals with requests and prompt, output and total tokens, a By model rollup for anthropic/claude and a By client rollup for Atlas, Historian and CollectorThe Usage page: filters for client, user, model, status and a time range, then Totals with requests and prompt, output and total tokens, a By model rollup for anthropic/claude and a By client rollup for Atlas, Historian and Collector

UsageLogEntry fields#

Field Type Recorded Privacy notes
Timestamp datetime Always UTC when the call completed.
ClientId string Always The OAuth token subject (client ID or User ID of the caller).
UserId string When applicable If X-Gage-Subject parses as a GUID (Pulse User), stored for audit. Otherwise null (opaque subject or client_credentials).
Model string Always The logical-model handle (providerId/logicalModelId). NOT the real model ID passed upstream.
PromptTokens, OutputTokens long Always Token counts estimated by a character-heuristic (~chars/4), not authoritative provider usage. Useful for quota accounting, not for actual billing.
Status int Always HTTP status (200, 429, 5xx, etc.).
Charges list Always Counter charges applied to enforce quotas on recovery after restart. Internal; not for external interpretation.

What is NOT logged#

  • API keys are never logged.
  • Prompt contents or response bodies are never logged. The usage log contains only metadata: tokens, status, model, timestamps.
  • Wire-specific request transformations are not logged. The log records the logical-model handle, not the rewritten body sent upstream.

Querying usage#

GET /api/gage/usage?clientId=atlas&from=2026-08-01&to=2026-08-08&take=50&skip=0
Authorization: Bearer <admin-token>

Query params (all optional): clientId, userId, model, from (ISO datetime), to (ISO datetime), status (HTTP code), skip (default 0), take (default 100).

Returns newest-first:

{
  "items": [
    {
      "timestamp": "2026-08-08T15:30:45Z",
      "clientId": "atlas",
      "userId": null,
      "model": "anthropic-prod/fast",
      "promptTokens": 500,
      "outputTokens": 200,
      "status": 200
    }
  ],
  "total": 1
}

API key storage and rotation#

API keys are stored verbatim in the configuration store, matching Pulse's existing secret posture. They are not hashed, encrypted per-key, or obscured; anyone with read access to the configuration database can read them. This is a deployment consideration: restrict database access accordingly.

Read-only key handling#

When you read a provider (GET /api/gage/providers/{id}), the API key is never returned. The response carries only a HasApiKey boolean flag indicating whether one is set. This holds for both the UI and machine API planes.

In the UI: Create and update forms have separate key fields. On create, you enter the key. On update, the key field is optional; leaving it empty preserves the stored key. To rotate the key, use the dedicated POST /app/gage/providers/{id}/set-key endpoint (UI plane) or update via the machine API with a new key.

On the machine API: Use PUT /api/gage/providers/{id} with a new key in the request to rotate.

Example: key rotation via UI plane#

POST /app/gage/providers/anthropic-prod/set-key
Cookie: [identity-cookie]

{
  "ApiKey": "sk-ant-[new-key-material]"
}

Returns 200 OK with the updated provider detail (key field masked; HasApiKey: true).


Walkthrough: add a provider and model, verify in the catalogue#

Before you start: a first-run Pulse already has an anthropic provider carrying a claude logical model, enabled but without a key. If Anthropic is your provider, you do not need this walkthrough - enter a key on that provider instead. See Quick Start: Turn On the AI Assistant. Follow the walkthrough below to add a second provider, or a provider of a different kind.

Step 1: Create a provider#

Send a POST request to the admin endpoint with a new provider:

POST /api/gage/providers
Authorization: Bearer <admin-token>
Content-Type: application/json

{
  "Id": "anthropic-prod",
  "DisplayName": "Anthropic (Production)",
  "Wire": 0,
  "Endpoint": "https://api.anthropic.com",
  "UpstreamPath": null,
  "ApiKey": "sk-ant-[your-api-key]",
  "Enabled": true,
  "Models": [
    {
      "Id": "fast",
      "DisplayName": "Claude Haiku (Fast)",
      "RealModelId": "claude-haiku-4-5",
      "FallbackEnabled": true,
      "Enabled": true
    },
    {
      "Id": "smart",
      "DisplayName": "Claude Opus (Smart)",
      "RealModelId": "claude-opus-4-1",
      "FallbackEnabled": true,
      "Enabled": true
    }
  ]
}

The response is 201 Created with the provider DTO:

{
  "Id": "anthropic-prod",
  "DisplayName": "Anthropic (Production)",
  "Wire": 0,
  "Endpoint": "https://api.anthropic.com",
  "UpstreamPath": null,
  "Enabled": true,
  "HasApiKey": true,
  "Models": [
    {
      "Id": "fast",
      "DisplayName": "Claude Haiku (Fast)",
      "RealModelId": "claude-haiku-4-5",
      "FallbackEnabled": true,
      "Enabled": true
    },
    {
      "Id": "smart",
      "DisplayName": "Claude Opus (Smart)",
      "RealModelId": "claude-opus-4-1",
      "FallbackEnabled": true,
      "Enabled": true
    }
  ],
  "CreatedAt": "2026-08-08T16:00:00Z",
  "UpdatedAt": "2026-08-08T16:00:00Z"
}

Note: HasApiKey: true, but the raw key is never returned.

Step 2: (Optional) add another model to the provider#

Update the provider to add a third model:

PUT /api/gage/providers/anthropic-prod
Authorization: Bearer <admin-token>
Content-Type: application/json

{
  "Id": "anthropic-prod",
  "DisplayName": "Anthropic (Production)",
  "Wire": 0,
  "Endpoint": "https://api.anthropic.com",
  "UpstreamPath": null,
  "ApiKey": "",
  "Enabled": true,
  "Models": [
    {
      "Id": "fast",
      "DisplayName": "Claude Haiku (Fast)",
      "RealModelId": "claude-haiku-4-5",
      "FallbackEnabled": true,
      "Enabled": true
    },
    {
      "Id": "smart",
      "DisplayName": "Claude Opus (Smart)",
      "RealModelId": "claude-opus-4-1",
      "FallbackEnabled": true,
      "Enabled": true
    },
    {
      "Id": "extended",
      "DisplayName": "Claude Opus (Extended Thinking)",
      "RealModelId": "claude-opus-4-1-20250805",
      "FallbackEnabled": true,
      "Enabled": true
    }
  ]
}

Note: ApiKey is empty (""), so the stored key is preserved. The response is 200 OK with the updated provider.

Step 3: Verify the models appear in the public catalogue#

Any authenticated caller (not admin-gated) can fetch the model catalogue:

GET /api/gage/models
Authorization: Bearer <any-app-token>

The response includes only enabled models from enabled providers:

{
  "models": [
    {
      "Handle": "anthropic-prod/extended",
      "DisplayName": "Claude Opus (Extended Thinking)",
      "Wire": 0
    },
    {
      "Handle": "anthropic-prod/fast",
      "DisplayName": "Claude Haiku (Fast)",
      "Wire": 0
    },
    {
      "Handle": "anthropic-prod/smart",
      "DisplayName": "Claude Opus (Smart)",
      "Wire": 0
    }
  ],
  "Default": "anthropic-prod/extended"
}

The list is sorted alphabetically by handle. Default is the handle the reserved word default resolves to on every consuming component, and it is whichever of these applies:

  1. the model an administrator marked with the Default Model toggle in the provider editor - install-wide, at most one across every provider, and marking one clears the flag on every other;
  2. otherwise the alphabetically first handle in the list, which is what an installation that has never chosen keeps using.

A change that would leave the installation with other models but no usable default is refused with a 400 naming the model that blocks it. Move the default by marking another model, not by clearing the current one. Disabling or deleting the last usable provider is always allowed.

Step 4: Confirm a consuming component can resolve and call the model#

A consuming app (e.g., Historian, Atlas) sends a proxy call:

POST /api/gage/proxy/v1/messages
Authorization: Bearer <app-token>
X-Gage-Subject: <optional-user-id>
Content-Type: application/json

{
  "model": "anthropic-prod/fast",
  "max_tokens": 1024,
  "messages": [
    {
      "role": "user",
      "content": "Hello, Claude."
    }
  ]
}

Gage resolves anthropic-prod/fast:

  1. Looks up provider anthropic-prod (found, enabled).
  2. Looks up model fast in that provider (found, enabled).
  3. Enforces pre-flight quota (based on X-Gage-Subject and client ID).
  4. Rewrites model field to claude-haiku-4-5.
  5. Injects max_tokens: 1024 as-is (already present, no injection).
  6. Forwards to https://api.anthropic.com/v1/messages with the rewritten body and x-api-key: sk-ant-[key] header.
  7. Streams the response back to the caller as SSE (if "stream": true) or JSON (if absent or false).
  8. Logs the call: ClientId (app token subject), optional UserId, Model: anthropic-prod/fast, tokens, status, timestamp.

If the call returns HTTP 429 or 5xx before streaming starts, Gage checks for a fallback model (anthropic-prod/fast with FallbackEnabled: true) on another provider of wire 0. If one exists (e.g., anthropic-backup/fast), it retries there.


Troubleshooting#

Symptom: "No provider exposes the model handle"#

Request: POST /api/gage/proxy/v1/messages with "model": "anthropic-prod/fast"

Response: HTTP 404, body: No provider exposes the model handle 'anthropic-prod/fast'.

Possible causes:

  1. The provider or model does not exist. Check step 3 above; list the catalogue with GET /api/gage/models.
  2. The provider or model is disabled (Enabled: false). Check the provider detail: GET /api/gage/providers/anthropic-prod. Set Enabled: true and re-test.
  3. The handle is malformed (not providerId/logicalModelId or segments contain uppercase/spaces). Verify the format is lowercase and matches ^[a-z0-9][a-z0-9-]*$.

Fix: Create the provider (step 1), enable it, and verify the catalogue (step 3).

Symptom: Request accepted, but upstream returns 4xx on the first proxy call#

Request: POST /api/gage/proxy/v1/messages with a valid handle.

Response: HTTP 400 or similar (upstream status), body from upstream error.

Possible causes:

  1. Wire mismatch (most common): The Wire you set does not match the actual backend. For example, you set Wire: 1 (OpenAI) but the backend is Anthropic. The proxy sends an OpenAI-format body to an Anthropic endpoint; the endpoint rejects it.
  2. Upstream path is wrong: UpstreamPath does not match where the backend actually listens. For example, you configured /v1/chat/completions but the backend serves /v1/completions.
  3. API key is invalid or expired: The upstream authenticates the request and rejects it because the key is wrong.
  4. No API key set: The provider has no key at all. A first-run Pulse's seeded anthropic provider is enabled from the start but has no key until you enter one, so the upstream refuses every call to it with a 401.

Fix:

  • Verify the Wire matches the upstream's native format (not its branding). See the Wire section.
  • Test the upstream directly with a curl or similar tool: curl -X POST <Endpoint>/<UpstreamPath> -H "Authorization: Bearer <key>" -d '...' for OpenAI wire, or -H "x-api-key: <key>" for Anthropic.
  • Set or rotate the API key: POST /api/gage/providers/{id}/set-key with a new key, then re-test.

Symptom: HTTP 429 (Too Many Requests) on a proxy call#

Response: HTTP 429, Retry-After header set, body: error message about quota.

Possible causes:

  1. A token quota (tokens.daily, tokens.hourly, etc.) is breached for the caller's Client or User policy.
  2. The enforcement policy is set to Throttle (1) or Block (2).

Fix:

  • Check the caller's policy: GET /api/gage/client-policies/{clientId} or GET /api/gage/user-policies/{userId}.
  • Increase the limit or extend the window: PUT /api/gage/client-policies/{clientId} with a higher Limit or longer Window.
  • Or change the policy to Soft (0) to allow the overage without blocking.
  • Check the usage log: GET /api/gage/usage?clientId={id}&from=<today> to see what was consumed.

Symptom: HTTP 403 (Forbidden) - "Requested handle is not in the caller's allow-list"#

Request: Valid handle, valid provider, but the response is HTTP 403.

Possible cause: The caller's Client or User policy has a non-empty AllowedModels list that does not include the requested handle.

Fix:

  • Check the policy: GET /api/gage/client-policies/{clientId}.
  • Add the handle to AllowedModels, or clear the list to allow all models: PUT /api/gage/client-policies/{clientId} with "AllowedModels": [].

Symptom: Consuming app (Atlas, Historian) reports no models available#

Possible causes:

  1. No providers are configured or all are disabled.
  2. No models are enabled (all have Enabled: false).
  3. The consuming app's token lacks the ApiAccess or ApiAdministratorAccess role.

Fix:

  • Create a provider (step 1) and enable it.
  • Enable at least one model: PUT /api/gage/providers/{id} with Models[*].Enabled: true.
  • Verify the catalogue is non-empty: GET /api/gage/models. It should return at least one model.
  • Confirm the app is using a valid Pulse API token with authenticated access.

  • Pulse authentication: See the Pulse configuration guide for OAuth client setup and token issuing.
  • Atlas Gage integration: Atlas uses the /api/gage/models catalogue and /api/gage/proxy/* front doors. No Atlas-specific configuration required.
  • Historian Gage integration: Similarly uses the catalogue and proxy endpoints; no agent role required for calling the proxy.
esc
↑↓ navigate · ↵ open · ⌘K anywhere