Skip to main content

Hosted API

The hosted service runs FastAPI on Cloud Run in skellyspeak-api, region us-central1. It authenticates accounts and meters chat, speech generation, and microphone transcription. Bring-your-own-key and custom-server requests are routed by the Rust core without passing through this service.

Request ownership

ModuleResponsibility
server/main.pyHTTP authentication, upstream calls, cancellation handling, responses
server/contracts.pyAllowed text/audio request shapes and price reservations
server/audio_input.pyBounded audio decoding and duration-based transcription pricing
server/budget.pyAtomic admission and idempotent dated settlement
server/transactions.pyLocal transaction serialization and bounded retries for contention
server/auth.py, server/auth_store.pyJWT/PKCE validation and atomic one-time code consumption
server/quota.pyAccount admission, session revocation, device records and usage reporting
server/reconcile.pyInspect unresolved charges and verify provider receipts

Endpoints

MethodPathPurpose
GET/healthLiveness; startup checks configuration and the audio decoder
GET/auth/startBegin Google sign-in in the system browser
GET/auth/callback/googleValidate identity and issue a one-time app code
POST/auth/exchangeExchange that code with its PKCE verifier
GET/v1/meIdentity and daily allowance
POST/v1/chat/completionsValidated, metered OpenRouter chat and speech
POST/v1/audio/transcriptionsValidated, metered Groq transcription

Authentication

Desktop sign-in uses a bound loopback listener; mobile uses a skellyspeak://auth deep link. Redirect targets are validated, and PKCE binds the one-time code to the initiating app. Firestore validates and consumes state/code documents transactionally, so concurrent exchanges have one winner. States expire after five minutes and exchange codes after two minutes.

Session JWTs last 30 days. Each authenticated request checks the account's token_version, so deleting an account or increasing that version revokes access. The runtime needs a signing key of at least 32 bytes. The native app stores its session and provider keys in the platform credential vault.

Sign-in-start throttling is process-local: 20 attempts/minute per instance. Instance replacement and distributed traffic can exceed one instance's rate; this is not a global denial-of-service or infrastructure-spend limit.

Every instance also rejects traffic above 240 attempts per rolling minute before application database access. Shared Firestore transactions count accepted auth steps (500/day), authenticated account requests (2,000/account/day and 10,000/service/day). These counters include later failures and never refund. Signed, purpose-bound OAuth state and exchange codes reject fabricated values before database lookup; one-time consumption and PKCE still apply. Sign-ins already in progress during this deployment must be restarted. Installation IDs must be canonical UUIDs, with at most ten device records per account. Daily admission records use a two-day TTL; expiry is enforced by UTC bucket selection, not by waiting for Firestore deletion.

These controls bound admitted work, not the cost of all rejected traffic. A public endpoint still needs edge abuse controls; Cloud Run instance counts and billing alerts are not hard monetary caps.

Spending admission and settlement

Money is stored in integer micro-dollars: 1,000,000 equals one US dollar. Every paid request atomically reserves its conservative maximum against both users/{id}/usage/{UTC-date} and global_usage/{UTC-date} before contacting a provider. It also creates users/{id}/reservations/{request-id}.

The client supports four concurrent inference calls. An unaffordable reservation is rechecked within the same server request before provider dispatch: at most six attempts, separated by 0.5, 1, 2, 4 and 8 seconds. Other admitted calls continue running. At most 16 requests per process may wait; overflow fails with HTTP 503. Spending pauses and requests larger than the entire allowance fail immediately. Persistent allowance rejection returns its specific HTTP 429 code after waiting. These checks do not repeat a provider request or lower the spending ceiling.

Output ceilings reflect the task: 2,000 tokens for word insight/topic notes, 4,000 for translation/mechanics/coach feedback, and 8,000 for suggestions. Long word annotations retain 32,000. Smaller models are a separate evaluation; the allowance fix does not change model selection.

Settlement corrects both totals in the original UTC bucket. Repeating an identical completed settlement has no effect; conflicting settlements fail. Missing or incomplete usage retains the full reservation for investigation. A reported charge above its reservation is recorded and blocks further admissions through the persistent service_controls/spending document, including after midnight. An operator must investigate before clearing that control; it has no TTL. Cancellation and provider HTTP errors cannot release an unverified charge.

The service accepts only the configured, priced model contracts:

  • google/gemini-2.5-flash: text-only input, disabled/minimal reasoning, strict structured output, and bounded output tokens.
  • openai/gpt-audio-mini: streamed PCM16 speech, a supported voice, at most 2,000 output tokens. Input audio is not accepted on this endpoint.
  • whisper-large-v3: JSON transcription of a decoded recording up to 120 seconds; price includes the provider's ten-second billing minimum.

Alternative model lists, caller-controlled routing, multimodal chat inputs, unknown fields and unbounded token limits are rejected. OpenRouter receives require_parameters and per-token price ceilings. Reservations depend on the provider honoring its token limits and pricing; they are not a guarantee against provider billing errors or every cloud infrastructure charge. OpenRouter documents the routing controls in its provider-selection reference.

Audio reserves its maximum transcription charge before reading or decoding the upload. Locally rejected audio releases that model reservation, but not its request-admission count. Upload reading has a 30-second deadline. Audio is decoded using a restricted ffmpeg input protocol, bounded duration, allocation/probe limits and a timeout. Two decodes/transcriptions can occupy an instance concurrently. Cloud Run admits eight requests per instance, with a maximum of four instances; these settings bound concurrency, not total bills.

/v1/me reports estimated_turns_remaining. This wire field counts individual AI requests; the client presents it as estimated_requests_remaining. A conversation turn can make several model calls, and this estimate uses average request cost. Pending reservations can temporarily reduce the displayed allowance.

The client labels this total as spent or reserved, including unresolved charges. Token totals are reporting, not the allowance limit. Hosted refusal messages use allowlisted error reasons and validated request IDs. Embedded refusal codes in HTTP-200 streams pause shared admission just as HTTP refusals do; the client does not automatically retry them. Both server deployment and a client release are needed to deliver the complete allowance/concurrency fix.

Reconciliation and retention

Run these from server/ with application-default credentials for the intended project. Receipt settlement also requires OPENROUTER_API_KEY in the environment. Do not put the key in a command argument or a committed file.

uv run python reconcile.py list
uv run python reconcile.py settle --user 'google:ACCOUNT_ID' --request 'REQUEST_ID'

The settle command verifies the stored generation ID against OpenRouter's receipt and uses its reported total cost and token counts. Requests without a provider generation ID stay reserved until their billing can be investigated. Do not clear daily totals to free allowance: outstanding reservations depend on them. Reconcile promptly; daily usage rows become eligible for TTL after 90 days.

Completed reservations, usage and device rows use the ttl timestamp field with 90-day retention. Pending/unknown reservations have no TTL. Sign-in records become eligible for deletion after one day; logical expiry applies independently of Firestore's asynchronous deletion schedule.

stats.py reports usage and can set a per-user daily limit. Its zero-dollar argument removes the override. It cannot reset the ledger. A report mismatch fails and should be rechecked while requests are idle, since report reads are not one atomic snapshot.

Deployment and IAM

server/cloudbuild.yaml builds an image and deploys it using [email protected]. The runtime identity is [email protected]:

  • Runtime: Firestore data access and access to five named application secrets.
  • Builder: artifact writes to gcr.io, reads from the build staging bucket, logs, service usage, deployment of the existing Cloud Run service, and permission to run it as the runtime identity.
  • GitHub deployer: submit builds, read build logs, use the staging bucket and select the build identity. Federation is restricted to this repository's numeric owner/repository IDs and the deployment workflow on main.

scripts/setup-gcp-deploy.ps1 -Phase Grants establishes scoped grants. Verify a candidate build before running -Phase Prune to remove broad project grants. The default compute builder retains scoped build access during rollout; it has no Editor, runtime-secret or Firestore-data grant.

The GitHub workflow gates deployment on server tests, independent-process Firestore emulator tests and container startup checks. Cloud Build checks that the intended image's ready revision receives all traffic; GitHub checks public health and unauthenticated rejection. Container base images and Python packages are pinned by digests/lockfile. Secret values are runtime bindings, never build substitutions.

gcloud builds submit --project=skellyspeak-api --config=server/cloudbuild-check.yaml --gcs-source-staging-dir=gs://skellyspeak-api_cloudbuild/source

This builds/tests/pushes a candidate without changing serving traffic. Changes to the allowance response require coordinated native-client and server releases. Keep the working revision serving until that release is ready.

Local checks

cd server
uv sync --frozen --group dev
uv run --frozen pytest -q

Install ffmpeg to exercise real decoding. Emulator integration additionally requires Java, the Firestore emulator on 127.0.0.1:8787, and SKELLYSPEAK_FIRESTORE_TEST=1. Tests use isolated project namespaces and refuse non-loopback emulator targets.

Hosted streaming HTTP failures include the upstream numeric status code without its response body. The app explains provider credit/payment failures (402); Google sign-in users depend on the hosted operator’s OpenRouter balance and API-key limit.

The existing word-insight operation also supplies on-demand inline meanings outside chat. Its structured response includes a required native-language gloss, in addition to lemma, part of speech, form, role and usage. These requests use the existing provider routing and metering path.

Reading text without saved word annotations is sent with its surrounding context to the configured model when the reading surface appears. This uses the existing tokenize operation and prepares glosses, pronunciation and romanization together. Ordinary word taps reveal these prepared annotations locally. Explicit deeper inspection remains a separate word-insight request. Annotation reuse is transient and scoped to the language pair and exact source/context; it is not a persistent dictionary.