| name | ai-gateway-setup |
| description | Install a central OpenRouter AI gateway into a Supabase + TypeScript project — validates the API key, creates the tables for per-tenant keys and per-function model configuration, adds the usage/cost log, drops in the gateway module and builds the FUNCTION_DEFAULTS map for the project's own AI functions. Use before adding the first AI feature, or as sprints 0 and 1 of migrating scattered LLM calls onto one gateway. |
| license | MIT |
| compatibility | Target project needs Supabase (Postgres with RLS, Deno Edge Functions) and the Supabase CLI. An OpenRouter account with credit and an API key is required to pass the sprint 0 gate. |
| metadata | {"version":"1.0.0","part-of":"openrouter-gateway"} |
Install the OpenRouter AI gateway
Covers sprints 0 and 1 of the plan: the key, the schema, the observability and
the gateway module. After this, features call callAI() and nothing else.
Source material is in the sibling skill directory:
../openrouter-gateway/references/ the plan (ORQUESTRADOR + sprint-0 … sprint-4)
../openrouter-gateway/assets/ gateway, migrations, examples
In Claude Code that resolves under ${CLAUDE_SKILL_DIR}/../openrouter-gateway/.
If the relative path fails:
find ~ -type d -path "*skills/openrouter-gateway/assets" 2>/dev/null | head -3
Copy the packaged gateway. Do not retype it — the retry logic, the error
classification and the fire-and-forget logging each exist because of a specific
failure.
Step 0 — decide three things
Ask the user; record the answers in the project's CLAUDE.md / AGENTS.md.
- Tenancy. The artifacts use
org_id + organizations +
user_is_org_member(). What is the equivalent here — company_id,
workspace_id, account_id, or single-tenant? Single-tenant keeps the column
with one fixed row; dropping it spreads exceptions through the gateway.
- Who pays. Three models, and it changes the UI, not the code:
- platform pays — one
OPENROUTER_API_KEY, tenants configure nothing;
- tenant pays — each brings its own key, no platform fallback;
- hybrid (recommended) — platform key as the default, tenant may bring
its own and then pays its own usage. The gateway already implements this.
- Spend cap. Will tenants have
monthly_limit_usd? The column ships in the
migration; the enforcement is sprint 4.
Step 1 — validate OpenRouter before writing any code
Create an API key dedicated to this application (not a personal one) and prove
it works. Do not trust "saved":
curl -s https://openrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer $OPENROUTER_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"google/gemini-2.5-flash",
"messages":[{"role":"user","content":"reply with just: ok"}],
"max_tokens":10}' | jq '.choices[0].message.content, .usage'
402 means no credit. 401 means the key is wrong. Neither gets better later.
Then store it:
supabase secrets set OPENROUTER_API_KEY=sk-or-v1-...
Step 2 — schema
Apply, in order, from ../openrouter-gateway/assets/migrations/:
| File | Creates |
|---|
00-ai_core_tables.sql | ai_api_keys, ai_model_configs, ai_favorite_models + RLS + GRANTs |
01-ai_usage_log.sql | ai_usage_log + indexes + RLS + GRANT |
02-verificacao-unicidade.sql | Verification — run it if the project already had the first two tables |
supabase migration new ai_gateway_core
supabase migration new ai_usage_log
supabase db push
Before pasting, rewrite the three adapter points marked ADAPTAR in the files:
tenant table, membership helper, admin helper.
Verify RLS and GRANT with real queries — this is where the stack fails most
confusingly:
insert into ai_usage_log (function_name, model, success)
values ('smoke-test', 'google/gemini-2.5-flash', true);
select count(*) from ai_usage_log;
delete from ai_usage_log where function_name = 'smoke-test';
An empty result with no error is correct. permission denied means the
GRANT is missing. Rows from another tenant mean the policy is wrong.
Step 3 — the gateway module
mkdir -p supabase/functions/_shared
cp ../openrouter-gateway/assets/ai-gateway.ts supabase/functions/_shared/ai-gateway.ts
deno check supabase/functions/_shared/ai-gateway.ts
Then adapt, in the file:
APP_URL / APP_TITLE — your domain and product name (OpenRouter
attribution headers).
- Tenant column — if you are not using
org_id, rename it in
resolveConfig and in logUsage.
FUNCTION_DEFAULTS — replace the three example entries. See step 4.
Step 4 — build FUNCTION_DEFAULTS
One entry per AI function in the project. The key is the function's folder
name, always.
ls supabase/functions | grep -vE "^_"
Criteria — a real 27-function map is in
../openrouter-gateway/assets/examples/function-defaults-exemplo.ts:
| Function profile | Primary | Fallback |
|---|
| Short text, high volume (suggestions, chat, descriptions) | Flash / mini | openai/gpt-4o-mini |
| Long form the user reads end to end (page, ebook, report) | Pro / Sonnet | another provider |
| Structured JSON the system parses | openai/gpt-4.1 | anthropic/claude-3.5-sonnet |
The fallback must be from a different provider. A fallback on the same
provider does not survive that provider's outage — which is the only reason a
fallback exists.
Confirm every model id exists before committing:
curl -s https://openrouter.ai/api/v1/models | jq -r '.data[].id' > /tmp/or-models.txt
grep -qx "google/gemini-2.5-flash" /tmp/or-models.txt && echo ok || echo AUSENTE
Preview models (*-preview) disappear. Do not put one in a fallback slot.
Step 5 — prove it end to end
Write or convert one function to callAI and check three things:
select function_name, model, is_fallback, prompt_tokens, completion_tokens,
cost_usd, latency_ms, success, error_code
from ai_usage_log order by created_at desc limit 5;
-
Happy path — a row with tokens > 0. cost_usd may be NULL if OpenRouter
did not report it for that model; that is acceptable.
-
Fallback — set an invalid primary model on purpose and confirm the call
still completes:
insert into ai_model_configs (org_id, function_name, primary_model, fallback_model, is_active)
values ('<tenant>', '<function>', 'modelo/que-nao-existe', 'openai/gpt-4o-mini', true)
on conflict (org_id, function_name) do update
set primary_model = excluded.primary_model, fallback_model = excluded.fallback_model;
Expect two log rows: one success=false with error_code='BAD_REQUEST',
one success=true with is_fallback=true. Then delete the test config.
-
No key — temporarily remove the secret and the tenant key; confirm the
frontend receives NO_API_KEY with a readable message. Restore.
Step 6 — lock the rule in
Append to the project's CLAUDE.md / AGENTS.md:
## AI calls
Every LLM call goes through `supabase/functions/_shared/ai-gateway.ts`
(`callAI` / `callAIVision`). A direct `fetch` to any model provider inside
`supabase/functions/` is a review blocker.
Rules: `functionName` = the function's folder name (it keys config, defaults and
logging); pass the tenant id whenever a session exists; every AI function needs a
FUNCTION_DEFAULTS entry with a fallback from a different provider; never log
prompts or completions.
Without this, the next session adds a direct fetch and the gateway starts
decaying on day one.
Gate before moving on
Then, if there are existing scattered LLM calls, continue with ai-migrate.