small gleam coding and persistent agent daemon with a detachable cli and a canvas ui forked from okami.mom/albedo
albedo robot-docs auth.md
46 kB · 351 lines
Markdown

model authentication #

model authentication is supplied by model-provider extensions rather than the daemon configuration loader.

credentials #

Every secret albedo keeps lives in $ALBEDO_HOME/creds.json, mode 0600, and only the daemon reads or writes it. accounts holds OAuth and key accounts by store key, as one object or an array. Key accounts contain type: "key", apiKey, endpoint, label, and _albedo_account_id; generic compatible keys use accounts.openai, OpenCode Go uses accounts.opencode-go, Perplexity uses accounts.perplexity, and Mistral uses accounts.mistral. providers holds legacy profile keys for other extensions; mcp holds each MCP server's bearerToken, headers, env, a configured OAuth client (oauthClient) and its OAuth sign-in (oauth, see mcp). All credential and settings mutations share one per-home daemon lock. OAuth refreshes re-read under that lock and preserve unrelated sections. A hand-written config apiKey wins at runtime: the next start moves it into a key account and pins the profile for openai, opencode-go and mistral, and leaves it in the profile for a provider that reads its own (bedrock, vertex, perplexity).

Account limit marks under accounts are recorded per model-family meter for Google Antigravity (limitedMeters: {"claude": <epoch ms>, ...} with meter keys gemini, claude, gpt-oss, falling back conservatively to legacy limitedUntil when limitedMeters is absent). Codex and saved API keys use account-wide limitedUntil. Environment keys keep transient marks for the daemon's lifetime.

At start the daemon moves secrets still kept elsewhere into creds.json: auth.json's accounts, mcp-credentials.json's servers, and the apiKey of a config.json profile whose provider keeps its keys as accounts (openai, Alibaba profiles included as they convert to it, opencode-go, and mistral). A provider that reads its own profile key (bedrock, vertex, perplexity) keeps it there, so a config.json may still hold one after a start. It writes creds.json first, then moves the old files into backups/ as <name>-before-creds-<ms> with every mode bit cleared, so an interrupted start loses nothing and the next one finishes. Not even their owner can read the backups without chmod 600 first, so tools and agents running as the user never read them by accident; rm -f deletes them.

Clients save provider profiles and MCP configuration through the settings API. Reads disclose credential presence, never secret values. The model gets the same view from commands.accounts() (the /accounts command, see commands), which adds each account's Google project and per-meter limits; the kernel's files.read and files.find refuse creds.json itself (see files). PUT /auth/keys/<id> saves key accounts; profile patches select them through account_id or account_ids. The write-only profile api_key remains a compatibility path: for a provider whose keys are accounts (openai, opencode-go, perplexity, mistral), it saves or reuses a key account and pins the profile. MCP secrets belong to PATCH /settings?group=mcp. Public and private settings changes share one journalled commit.

GET /server includes credential migration notices for files moved during this daemon's start. Reading a notice does not consume it. Clients dismiss notices through ui.dismissed_notices. Original files remain sealed in backups/.

openai-compatible api keys #

The enabled-by-default openai extension requires models. It handles existing named OpenAI-compatible profiles and both Responses and Chat Completions. The Responses stream decoder accepts servers that leave output_index off their events (llama.cpp's llama-server) by placing each event with its item_id, and treats a missing content_index as 0. Model discovery asks the profile's endpoint for /models and falls back to the models.dev cache (see provider extensions). Older config.json profiles without an extension field remain OpenAI profiles; newly saved profiles include "extension": "openai".

Key accounts carry their own endpoint, including when an explicit rotation spans endpoints. Compatible API 429s containing insufficient_quota, exceeded your current quota, exhausted, Arrearage, Overdue, or billing record a 15-minute limit; other 429s record 30 seconds. Rotation uses only chosen members. When no other member is usable, a request waits out a brief limit on the same key, retrying after 4, 8, 15 and 30 seconds; cache-warming pings do not wait. A lasting limit on one plain key fails at once without trying another saved key.

Environment accounts have ids such as env:OPENROUTER_API_KEY, label $OPENROUTER_API_KEY, kind: "key", and source: "env". The cached models.dev provider whose API host matches the endpoint supplies environment variable names. Saved profile references to env:<VAR> also advertise that variable at the same store and endpoint, so migrated references remain usable. Unset variables are skipped within rotations; an unset pin fails with the variable's name. No environment value is written to creds.json.

chatgpt codex oauth #

The enabled-by-default codex extension requires openai, so it reuses the OpenAI Responses encoder, SSE transport, replay format, and model metadata. Its picker shows the models the ChatGPT backend offers the signed-in account (see codex models); API-key OpenAI profiles still show the full OpenAI catalog. /login can add ChatGPT Plus/Pro accounts. A fresh install can enter codex as the provider name; an existing install picks the ChatGPT row under new profile.

The daemon runs the login (see sign-in api) with the Codex CLI browser flow: PKCE S256, the allowlisted http://localhost:1455/auth/callback, state validation, and a manual callback-url/code input that races the local callback. The token exchange uses OpenAI's public Codex client id and stores access, rotating refresh, expiry, ChatGPT workspace id, seat id when present, and email when present.

Credentials live in creds.json's accounts under the openai-codex key. One account uses the legacy object shape; two or more use an array. A login replaces the same seat/account identity and appends a different account. /login lists saved providers and each stored account; ctrl+d on either row asks to remove it. Removing the active provider makes the first remaining provider by name active. A Codex provider with no accounts left reads signed out and starts a sign-in when chosen. Access tokens refresh under the credential lock 60 seconds before expiry, with a re-read under the lock so concurrent sessions do not overwrite a newer refresh.

A profile pins one account with account_id, rotates only through an explicit account_ids list, or remains plain. A plain sign-in profile uses its selected stored account, else its first stored account; limits and authentication failures never select a sibling. Explicit rotations keep session-sticky ordering and skip limited members. A plain Codex profile retains a rejected account and reports run /login; a rotating profile removes the rejected account and drops its id from saved rotations in the same commit. Requests use https://chatgpt.com/backend-api/codex/responses with Bearer auth, chatgpt-account-id, Codex experimental Responses headers, and the session request id. Codex request bodies force store: false, automatic parallel tools, low text verbosity, encrypted reasoning replay, and nullable tool strictness.

The oauth callback binds only to 127.0.0.1. Port 1455 is fixed because OpenAI allowlists the exact redirect URI; a busy port fails instead of silently changing redirects.

profiles in /login #

/login lists profiles, accounts (signed-in accounts plus saved and env API keys) and new-profile presets; Alibaba, OpenRouter, and Ollama Cloud are presets of openai, with no dedicated extension. A profile either pins one account or rotates an explicitly ticked set; nothing joins a rotation silently. Choosing a credential for a profile opens a chooser: enter pins the highlighted account, space ticks accounts, enter with two or more ticked saves a rotation (account_ids), and enter on "another key/account" adds one and, for a rotating profile, asks "add

sign-in api #

Browser sign-ins run in the daemon, so every client shares one OAuth implementation. An extension contributes extension.LoginPlugin(oauth.Login(...)), or a LoginResolverPlugin that names sign-ins at request time (one per MCP server, provider mcp:<server>): the profile extension it serves, a chooser label and detail, the protocol its profiles use, its creds.json accounts key, its loopback callback (host, port, path, and whether the port is fixed), and three functions. authorize builds the provider url from a Grant (redirect, state, PKCE verifier and challenge), or says why the sign-in cannot start. oauth.callback_issuer() gives exchange the response's iss (RFC 9207). exchange trades the code for the credential object to store and may report progress. account names a stored credential; equal ids are the same account. albedo_oauth owns the rest: the callback listener, the race with a pasted code, a 5-minute timeout, and locked writes. A sign-in replaces the stored account with the same id and appends a new one. One account is stored as an object, two or more as an array.

Clients use these resources with the daemon bearer token:

  • GET /auth returns advertised providers and forms, saved account descriptors, and incomplete login resources.
  • PUT /auth/logins/<id> creates a login at a client-generated UUIDv7 with {provider, flow, values}. Browser and manual flows use an empty values object. A known identical intent recovers the same resource; another intent conflicts. Creation returns 201; recovery returns 200.
  • GET /auth/logins/<id> returns retained state, browser URL, progress, completion account, and validator. States are waiting, exchanging, complete, failed, or cancelled.
  • PATCH /auth/logins/<id> with {response} and If-Match delivers a pasted callback URL, code#state, query string, or code. Callback and manual response compete for one exchange.
  • DELETE /auth/logins/<id> cancels the login and closes its listener.
  • PUT /auth/keys/<uuidv7> with {provider, endpoint, api_key, label?} saves a key account (201), or recovers an identical intent (200). Different content at that id is 409 conflict; a duplicate endpoint and key is 409 key_exists with the existing id.
  • PATCH /auth/accounts/<id> with {label} renames an account and returns its descriptor.
  • DELETE /auth/accounts/<id> removes a saved account and drops it from rotations in the same commit. A pin or last rotation member is 409 account_in_use. Environment PATCH and DELETE return 409 account_external.

Login identity and intent survive restart. Callback and PKCE material remain private. The daemon admits at most eight unfinished logins and retains terminal decisions for fifteen minutes. Browser launch remains client policy and respects ALBEDO_NO_BROWSER.

Clients list models through GET /models?provider_profile=<profile>. Profile defaults and credential selection belong to conditional provider settings patches. account_id pins one account; account_ids lists 1 to 50 accounts for explicit rotation. Both non-null is credential_conflict; an id outside the extension's saved or environment accounts is account_unknown. Neither set means one credential, never implicit rotation.

claude code oauth #

The enabled-by-default claude extension adds Claude Pro/Max accounts through /login using Anthropic’s Claude Code OAuth client. It stores rotating access and refresh tokens in creds.json's accounts under anthropic, refreshed under the credential lock. An account stored without a refresh token (a legacy or hand-edited one) is never refreshed: the request path skips it, and the quota poll keeps it so its auth failure is recorded. The fixed http://localhost:53692/callback loopback redirect must be available; a pasted callback URL also works. Choose a Claude model after signing in; profiles use extension: "claude" and protocol: "chat_completions". The picker shows Opus 5.5, Fable 5.1, Sonnet 5, Opus 5, Opus 4.8, and Haiku 4.5 only when the cached models.dev Anthropic catalog contains them. /reload models refreshes this availability and the models’ limits. New model families still require a picker-policy update.

Requests go to Anthropic’s Messages endpoint with Claude Code’s OAuth beta, client headers, billing attribution block, identity system block, and tool names. The billing marker is text in system[0], not an HTTP header; its CCH attestation hashes the exact outgoing JSON body. Top-level tool-schema combiners are flattened into an object with their property alternatives and common required fields; nested schemas remain intact because Anthropic rejects top-level oneOf/allOf/anyOf. This transformation lives beside the request encoder in harness/extensions/claude/schema.gleam and preserves unrelated schema fields, including definitions and custom metadata. Replay is saved as provider-neutral chat messages so changing models retains conversation and tool history. This is subscription access, not an Anthropic API-key profile. Anthropic can change or restrict this unofficial client behavior at any time.

google antigravity oauth #

The enabled-by-default antigravity extension signs in with the Antigravity IDE's Google OAuth client and sends requests to Cloud Code Assist (https://daily-cloudcode-pa.googleapis.com) for Gemini 3, Claude, and GPT-OSS models. /login lists it under new profile; a fresh install can also enter antigravity as the provider name. The daemon runs the sign-in through the sign-in api. Its callback is 127.0.0.1:51121/oauth-callback, which moves to an ephemeral port when 51121 is busy. After the token exchange it finds or provisions the account's Cloud Code Assist project: loadCodeAssist, a free-tier eligibility check, and onboardUser when the account has no tier yet, with progress shown in the client. An account that Google wants verified fails with the verification url.

antigravity/errors.gleam decodes error.details from the raw response before display truncation. Both streaming and sign-in use that decoder; never extract JSON fields from a formatted error with a regex. Sign-in uses the Google Accounts fallback only when VALIDATION_REQUIRED has no usable URL.

Credentials live in accounts.google-antigravity, in the one-object-or-array shape: {type: "oauth", access, refresh, expires, projectId, email?, selected?}. albedo_credentials:bound_values confines requests and limit summaries to the profile's pin, explicit rotation, or one selected/first account. Access tokens refresh under the credential lock 60 seconds before expiry. A 401 expires that token so the same account can refresh; an invalid refresh token leaves a plain profile signed out without trying a sibling.

Antigravity profiles use extension: "antigravity" and must use protocol: "chat_completions". The extension's upstream translates albedo requests into the Cloud Code Assist envelope, sends it through openai_api.exchange (the shared HTTP and SSE runner), and reduces Gemini's stream back into albedo events. Each assistant turn is stored as a Chat Completions message, so history, projection, and other providers read it unchanged. The exact Gemini parts, including thought signatures, travel in reasoning_details as an antigravity.parts entry and are replayed only to the model that produced them, at any of its effort tiers (gemini-4-argon-medium output replays exactly to gemini-4-argon-high, as oh-my-pi's collapsed ids do): re-encoding history on an effort switch would make the whole context an uncached prefill. Should Google refuse a request with a 400 that names a signature while it carries another tier's parts, the request goes once more with those turns portable (wire.ExactVariant). Unsigned Gemini thought summaries are left out of the parts (Gemini signs the parts after them, not the summaries; gemini-cli drops them as well); they still show as the reply's reasoning text. Turns stored before that keep theirs, so no existing session's prefix changes. Without a requested effort, the thinking level or budget follows the tier the resolved variant id names. Output from other models replays as plain text and tool calls; Gemini function calls without a signature get Google's documented skip_thought_signature_validator placeholder.

A Gemini variant id (gemini-3.8-flash-low, -medium, -high) is a preset of one model: the thinking comes from the request's thinkingLevel whichever variant carries it. Cloud Code still caches each variant apart, so an effort never moves a Gemini id (catalog.resolve_variant): a session stays on the variant its newest replayed turn ran on (wire.last_variant), a base id with no such turn resolves to its default variant, a variant id stays itself, and the effort travels as the thinking level, so /effort keeps the cached prefix. Claude variants still follow the effort. Gemini 3 Pro, 3.1 Flash Lite, 3.6 Flash and later Flash, and every model from 4 on take levels; 3.8 Flash accepts a budget but treats it only as a rough level (4000 and 10000 think alike).

Requests use the antigravity/hub/<version> user agent. The version comes from the Antigravity update manifest, or from ALBEDO_ANTIGRAVITY_VERSION when it is set. Tool schemas are flattened to the subset Cloud Code Assist accepts: references are resolved, unions keep their first non-null member, const and enums become string enums, and dropped validation keywords move into the description.

amazon bedrock #

The enabled-by-default bedrock extension serves Claude models through Bedrock's Anthropic-shaped Messages route ({baseUrl}/anthropic/v1/messages), identical on the bedrock-runtime and bedrock-mantle endpoints — baseUrl picks which host. There is no sign-in flow. /login offers add or update an Amazon Bedrock provider, which asks for an optional base url and optional api key and then lists the models.dev amazon-bedrock catalog; a profile can also be written with extension: "bedrock" and a Claude model id. baseUrl is optional: left unset, it defaults to bedrock-runtime in whatever region AWS_REGION, else AWS_DEFAULT_REGION, else the active AWS_PROFILE's own region key names (extension.effective_base_url); set it explicitly for bedrock-mantle or a non-default partition.

Authentication tries, in order: the profile's own apiKey (sent as x-api-key, no signing); AWS_BEARER_TOKEN_BEDROCK; AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKEN, signed with AWS SigV4 (harness/extensions/bedrock/sigv4.gleam); and finally AWS_PROFILE's own resolution from ~/.aws/config/~/.aws/credentials (AWS_CONFIG_FILE/AWS_SHARED_CREDENTIALS_FILE override the paths) — static keys from the shared credentials file, else static keys from the config file, else the config's credential_process. A credentials-file-only profile needs no config section. Credential sources are not mixed. Nothing is cached: each outbound model step re-reads the files and, when selected, re-runs the helper, so an external credential refresher takes effect within a tool-running turn. SigV4's service name follows each endpoint's own IAM action namespace (bedrock:* on bedrock-runtime, bedrock-mantle:* on bedrock-mantle); only bedrock-runtime has been exercised against a live account.

Only HTTPS AWS bedrock-runtime/bedrock-mantle hosts are accepted; embedded credentials, ports, non-root paths, query strings, and fragments are rejected before authentication. A credential_process is a trusted local executable: quoted arguments are parsed without a shell, and it inherits the daemon's environment. Its output and runtime remain bounded, and failure messages never include its stdout. The usage-feed command runner retains its separate environment allowlist. This is a limited credential chain, not the AWS SDK: native SSO, assume-role chains, and instance/container metadata are not resolved.

Replay keeps assistant text and tool calls only; signed extended-thinking blocks are not carried across turns, so multi-turn thinking context starts fresh each turn. The SSE reducer is claude's own (harness/extensions/claude/stream.gleam): Bedrock's Messages route emits the identical event shape.

Converse and ConverseStream #

A Bedrock profile may opt into the cross-model messages API with "api": "converse". /login offers a separate Amazon Bedrock Converse row. The existing Bedrock row and profiles without api retain the Claude Messages route; "api": "messages" selects it explicitly.

{
  "active": "bedrock-converse",
  "providers": {
    "bedrock-converse": {
      "extension": "bedrock",
      "api": "converse",
      "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com",
      "model": "amazon.nova-pro-v1:0",
      "protocol": "chat_completions"
    }
  }
}

protocol describes Albedo's durable replay envelope, not the AWS wire API. The provider uses ConverseStream at /model/{modelId}/converse-stream and accepts foundation-model IDs or inference-profile IDs/ARNs. It signs the actual encoded path with SigV4, or sends a Bedrock API key as Authorization: Bearer. Credentials are still resolved afresh on each request. Converse is supported on bedrock-runtime, not bedrock-mantle; an invalid target fails before auth.

The adapter maps text, images, tools, tool results, stop reasons, and usage to Albedo's existing events. Client tool calls may omit toolUse.type or use the exact "tool_use" marker returned by Claude. That marker and native metadata are preserved for same-model replay. Server-managed "server_tool_use", unknown types, and malformed type values remain refused, never local tool proposals. AWS's binary EventStream framing is checked for lengths, header validity, both CRCs, and UTF-8. A tool proposal is published only once the HTTP stream ends with a complete message, closed content blocks, and usage metadata; a truncated stream or a late provider exception fails the turn. Cancellation closes an unfinished response rather than returning it to the connection pool.

Converse supports only models and features AWS exposes through this API. Text and automatic tool choice work across supported model families; forced tool choices have model-specific restrictions. Reasoning signatures and opaque reasoning blocks are preserved in native replay and bound to the exact model ID. Replaying those native blocks with another model is refused; start a new session rather than silently losing signed reasoning. Verified reasoning-effort controls are described below. Structured-output and parallel-tool controls are still refused rather than guessed. The tool definition's strict flag remains a provider-specific hint, as in Albedo's existing Claude Messages adapter. This adapter sends the original schema but does not request or promise Bedrock strict-tool enforcement. This keeps ordinary tools usable across Converse model families; Albedo still validates a complete tool call before execution. Bedrock strict-tool support and its restricted schemas need a separate explicit mapping. Prompt-cache options are not yet mapped to explicit Converse cache points. Documents, video, and audio are not exposed by Albedo's request type.

The existing Claude Messages transport remains separate and keeps its current behavior. Converse support is covered with fake credentials and loopback protocol fixtures; no live AWS inference is implied by those tests.

Converse reasoning effort #

An explicit effort uses additionalModelRequestFields on both Converse operations. No effort leaves the request body byte-for-byte unchanged. Effort is a provider-specific behavior setting, not a thinking-token budget. Albedo never invents a budget, raises maxTokens, changes the selected model, or removes incompatible options to make a request pass.

The following mappings were checked against public provider documentation on 2026-10-27. IDs and geographic profile prefixes must match the published model cards exactly; a newer-looking name is not evidence of capability.

Model IDs (before a documented geographic prefix) Accepted efforts Additional request fields
anthropic.claude-opus-4-5-20251101-v1:0 low, medium, high output_config.effort; anthropic_beta: ["effort-2025-11-24"]
anthropic.claude-opus-4-6-v1, anthropic.claude-sonnet-4-6 low, medium, high, max thinking: {"type": "adaptive"}; output_config.effort
anthropic.claude-opus-4-7, anthropic.claude-opus-4-8, anthropic.claude-opus-5, anthropic.claude-opus-5-5, anthropic.claude-sonnet-5, anthropic.claude-sonnet-5-5, anthropic.claude-haiku-5-5, anthropic.claude-fable-5-1, anthropic.claude-mythos-5-1 low, medium, high, xhigh, max thinking: {"type": "adaptive"}; output_config.effort
anthropic.claude-fable-5 low, medium, high thinking: {"type": "adaptive"}; output_config.effort
amazon.nova-2-lite-v1:0 low, medium, high reasoningConfig: {"type": "enabled", "maxReasoningEffort": "…"}
openai.gpt-oss-20b-1:0, openai.gpt-oss-120b-1:0 low, medium, high reasoning_effort

Claude adaptive thinking rejects forced tool choice (any or a named tool). Its sampling policy depends on the exact model:

Adaptive models Explicit sampling with effort
Mapped Opus, Sonnet, and Haiku models temperature and top_p are rejected
Fable 5 temperature must be 1.0 or unset; top_p must be >= 0.99 and < 1.0, or unset
Fable 5.1 and Mythos 5.1 temperature must be 1.0 or unset; top_p must be 0.99 or unset; the two cannot be supplied together

These policies follow the model cards. Fable 5's card does not specify the 5.1 models' mutual-exclusion rule; Albedo does not infer that restriction from the newer models. AWS's general Claude sampling guidance recommends adjusting only one sampling control at a time. Accepted values are kept exactly as supplied in inferenceConfig, not removed or rewritten.

Nova's high effort rejects temperature and top_p; low and medium keep those values. These conflicts fail locally, not after silently changing the request. Opus 4.5's effort does not by itself enable extended thinking. Older Claude models with budget-only thinking have no qualitative-effort mapping here.

minimal, auto, none, adaptive, and unknown values are not aliases for supported effort levels. DeepSeek R1/V3.1, Nova 1, other Nova models, and closed-weight GPT models (including GPT-6.1 Sol) have no verified Converse effort mapping in this adapter. Reasoning output or a reasoning-summary option alone does not establish an effort control. Explicit effort for these models fails with Unsupported; omitting effort keeps their normal request usable.

A foundation-model ARN is supported only when its resource is an exact mapped bare model ID and its account is empty. A system inference-profile ARN must contain an exact documented geographic model ID and a 12-digit account. Both ARN forms require a region matching AWS's modelId grammar: [a-z0-9-]{1,20} (1–20 lowercase ASCII letters, digits, or hyphens). This is a syntax check, not an availability lookup. The model ID used in the URL and signature is never rewritten. Application inference profiles, custom/imported/provisioned models, prompts, and opaque ARN resources cannot reveal their underlying capability reliably; explicit effort is refused. Use a documented model or system inference-profile ID when requesting effort. The allowlist describes encoding, not region availability, account access, or successful live inference.

Sources:

Changing Claude's top-level effort may invalidate provider-side message prompt caches. This adapter does not rewrite replay blocks or add cache points, per-turn system messages, or beta per-message controls to avoid that cost.

google vertex #

The enabled-by-default vertex extension serves Gemini models through Vertex AI's own generateContent/streamGenerateContent route — the same request and response body Google documents for the public Gemini API, not Cloud Code Assist's envelope. There is no sign-in flow: /login offers add or update a Google Vertex AI provider, which asks for the project and location and lists the models.dev google-vertex catalog. The extension reads Application Default Credentials from GOOGLE_APPLICATION_CREDENTIALS (default ~/.config/gcloud/application_default_credentials.json) fresh on every call, and the profile's project and location name the project and region, falling back to GOOGLE_VERTEX_PROJECT/GOOGLE_VERTEX_LOCATION when blank. A profile needs extension: "vertex" and a Gemini model id.

The credentials file is either a service-account key (private_key/client_email; the daemon signs its own RS256 JWT and exchanges it at token_uri) or a refresh-token credential (client_id/client_secret/refresh_token) — gcloud auth application-default login's own shape or Workforce Identity Federation's external_account_authorized_user. The latter's STS token endpoint (its token_url field) takes client credentials as HTTP Basic, not body fields; the plain authorized_user shape takes them in the body. There is no token cache: each call re-reads the credentials file and exchanges a fresh access token, so an external refresher that rewrites the file in place just works.

Vertex passes tool schemas as parametersJsonSchema and structured response schemas as responseJsonSchema, without Cloud Code Assist's schema flattening. Supported explicit reasoning efforts map to Gemini 2.5 token budgets or Gemini 3 thinking levels; unknown family/effort combinations fail locally instead of being ignored. Unspecified effort keeps the provider default. The global location uses the bare aiplatform.googleapis.com host for both transport and upstream identity.

Malformed present stream fields fail decoding, and EOF without a terminal finishReason is an incomplete response, not success. Only a normal STOP completion publishes executable tool calls; limited or filtered responses cannot execute partial calls. Output token totals include thinking tokens, with reasoning also reported separately.

Replay keeps text, thinking text, and tool calls only, each replayed call marked with Google's documented placeholder signature (skip_thought_signature_validator) rather than the native one antigravity preserves for its own Gemini calls.

opencode go #

The enabled-by-default opencode-go extension serves OpenCode Go subscription models across OpenCode Go's four inference routes (https://opencode.ai/inference/go/v1, or https://opencode.ai/zen/go/v1): Chat Completions (/chat/completions), OpenAI Responses (/responses for GPT, Grok, and Muse Spark), Anthropic Messages (/messages for Claude and MiniMax M2.7), and Google Gemini (/models/{id}:streamGenerateContent?alt=sse), keeping durable replay in portable Chat Completions shape so switching models within a session never crosses protocols. /login offers both add opencode go account (the OpenCode Console RFC 8628 device-code browser sign-in against https://opencode.ai/console, overrideable via OPENCODE_CONSOLE_SERVER) and add or update an OpenCode Go provider (for a service-account API key and optional base url), and lists the models OpenCode Go serves (opencode-go-models.json cached for 24h from {baseUrl}/models, falling back to the models.dev opencode-go catalog).

Device-code sign-in requests an org-scoped grant (client_id: "opencode-cli", supports_org_scope: true), opens the verification URL in the browser with the confirmation code, polls /auth/device/token, and saves {type: "oauth", access, refresh, expires, accountId, email?, orgId?, orgName?, server} in accounts.opencode-go. Refresh runs under the credential lock 60 seconds before expiry. The profile's explicit pin or rotation selects saved OAuth accounts, saved key accounts, or environment accounts. A plain profile uses its own legacy key, else one selected/first saved account, else its environment key. Sibling profiles never join the runtime pool. Outbound requests carry x-opencode-session set to the session id and x-opencode-org-id for org-scoped accounts.

opencode zen #

The enabled-by-default opencode-zen extension serves the same gateway's Zen catalog (https://opencode.ai/zen/v1, its /zen/v1 now, /inference/openai/v1 on the console's own config, both rewritten to one root) over Chat Completions, with the Responses route for the muse-spark-* contributor models that answer 400 to a chat request. /login offers add or update an OpenCode Zen provider: the base url defaults, and the API key may be left blank, because the free models need no credential. It lists the models Zen serves (live {baseUrl}/models, cached for 24h in opencode-zen-models.json, falling back to that file, with models.dev's opencode namespace supplying the limits, modalities, and reasoning tiers), so a -free id the gateway adds or retires reaches the picker without an albedo release. A key, the profile's own or OPENCODE_ZEN_API_KEY/OPENCODE_API_KEY in the environment, is what a paid model spends.

A request wears the free lane when its model is one the lane serves — an id ending in -free, or an alias like big-pickle that the gateway serves free without saying so — or when the profile carries no credential at all, since the free lane is the only one reachable without one. A paid model on a keyed profile goes out with that key and albedo's own shape.

Zen answers a -free model only for a request shaped like the one OpenCode's own client sends, and refuses anything else with 403 FreeTierError. Three things are checked, all live-probed against the gateway: the client's identity headers (user-agent: opencode/<channel>/<version>/<artifact>, x-opencode-client: cli, x-opencode-session/x-opencode-session-id/x-session-affinity/x-session-id carrying the same id, x-opencode-project), a session id in the shape OpenCode mints (ses_, twelve lowercase hex, fourteen base62, its hex an inverted millisecond clock whose low twelve bits are a per-millisecond counter the gateway reads back and refuses past sixty-four, so every albedo session id ends in ffe), and one of two body shapes. An agent request — any system prompt but OpenCode's own utility prompt — must carry a tool named bash, glob, grep, or read in its list; albedo's tools are named differently, so shape_for_free_lane appends the reserved quartet, described as never to be called, and the model's own tools sit in front of them untouched. A request whose system prompt is OpenCode's own utility prompt needs no tools at all, and the two shapes are exclusive, so the quartet is what albedo sends. Authorization is the literal public, which is the whole credential the free tier takes.

A paid model wears the same identity — it is harmless and the gateway likes it — but keeps the profile's key and albedo's shape, tools included. The Responses route reads a tool marked strict by strict's own rules, where every property is required, which an optional parameter is not, so that route marks tools not strict before sending; Chat Completions reads the flag and leaves the schema alone.

perplexity #

The enabled-by-default perplexity extension serves Perplexity's Agent API as a model provider and Perplexity's Search API as a web search backend, on one API key. /login offers add or update a Perplexity provider: the base url defaults to https://api.perplexity.ai/v1, and the API key is saved as a key account, so it can be pinned, rotated through an explicit list, or shared by several profiles. PERPLEXITY_API_KEY serves a profile that saves no key, and PERPLEXITY_BASE_URL stands in for a profile that names no base url. Profiles use extension: "perplexity" and protocol: "responses".

The Agent API speaks the OpenAI Responses schema as Perplexity documents it, so requests go to the canonical {baseUrl}/agent and arrive as the same SSE events the OpenAI transport reduces — text deltas, function_call items, usage — while the body is Perplexity's documented subset: every input item carries its type (message, function_call_output, or a replayed output item verbatim), and a request carries none of include, parallel_tool_calls, prompt_cache_key, or text. A session's max_output_tokens is sent when it has one; Perplexity answers 400 to an anthropic/* request that names none, so that family gets the model's own published maximum from the catalog instead of a made-up budget. A JSON-schema output format travels as response_format, which is the only shape Perplexity takes; a bare JSON-object format is refused locally instead of silently dropped. Images go as input_image parts with data urls. A replayed output item goes back as it came when it is one of the API's input item types — a message, a function_call, its result, an MCP call or approval — while its output-only items (search_results and the other built-in tools' results, which nothing albedo asks for produces) are left out rather than sent as input the API does not accept.

The picker lists what {baseUrl}/models reports, remembered for five minutes per endpoint and key so listing every profile does not ask once per row; a changed key is asked at once. Model ids are Perplexity's own vendor/model slugs (openai/gpt-5.6-sol, anthropic/claude-sonnet-5, perplexity/kimi-k3), and models.dev still answers what it knows about each id, matched by its trailing segment. Perplexity publishes its catalog server-side, so a model it adds reaches the picker without an albedo release, and the extension hardcodes none.

Authentication tries the profile's own key account, its pin or explicit rotation; a plain profile with no key reads PERPLEXITY_API_KEY, and a hand-written profile apiKey is used as it stands, never moved into an account. A rejected credential answers run /login and save a Perplexity API key, a 429 records a limit against that key, and an unknown model id names /model rather than passing on the provider's 400.

mistral #

The enabled-by-default mistral extension serves Mistral's own API (https://api.mistral.ai/v1). /login offers two ways in: sign in to mistral, the browser sign-in Mistral Vibe uses, and add or update a Mistral provider with a pasted API key. The sign-in starts a process on console.mistral.ai/api/vibe/sign-in with a PKCE challenge, polls it while the person signs in, and trades the exchange token and verifier for an API key; the key does not expire, so it is saved as a key account in accounts.mistral exactly like a pasted one, with no endpoint of its own. A status check may fail three times in a row before the sign-in gives up, and the console's URLs must stay on its origin. Key accounts can be pinned, rotated through an explicit list, or shared by several profiles. A profile that pins none uses the selected saved account, then the first, then MISTRAL_API_KEY, skipping one still limited by a 429; MISTRAL_BASE_URL stands in for a profile that names no base url. Profiles use extension: "mistral" and protocol: "chat_completions".

Requests go to {baseUrl}/chat/completions through the shared client under the Mistral policy. Mistral refuses any field it does not document, so the body sends max_tokens rather than max_completion_tokens and no stream_options (usage comes in the last chunk unasked), and it carries the session id as prompt_cache_key. reasoning_effort is the session's effort as models.dev lists it for the model (none/high for most, max or low for a few hosted models), so a model without efforts sends none.

A thinking model streams delta.content as a list of thinking and text chunks; albedo_mistral:chunk reads them into reasoning_content and content before the shared reducer sees them. Replayed history is rewritten per request (wire.prepare): an assistant message keeps only role, content and tool_calls, its reasoning goes back as a thinking chunk ahead of its text when the request reasons and is dropped at effort none, and every tool call id, in the assistant message and in its tool result, becomes nine letters and digits (Mistral's own ids pass through; another provider's id maps to the same nine every time). A message left with neither content nor tool calls is dropped, since Mistral refuses one. Mistral takes no user message straight after tool results, so an assistant bridge (I have processed the tool results.) stands between them, and a tool result's images, which Chat Completions carries in a user message, follow that bridge. This is the shape Mistral Vibe, pi's mistral-conversations provider and oh-my-pi's Mistral rules send.

The picker lists the models {baseUrl}/models marks with both completion_chat and function_calling, remembered for five minutes per endpoint and key; without a key it falls back to models.dev's mistral namespace minus its embedding, OCR, moderation and audio models. models.dev names no endpoint for Mistral, so the extension's catalog answers lookups at api.mistral.ai and at any base url a Mistral profile names, with models.dev's mistral facts. A rejected credential answers run /login to sign in again or save a Mistral API key, a 429 records a limit against that key, and a 404 or an invalid-model 400 names /model.

Account ordering #

harness/accounts.gleam owns pure pool ordering and the 32-bit FNV-1a session spread. The native account owner captures wall time and process-local affinity before calling it. Selected usable accounts come first, followed by the sticky or hashed order; limited accounts come last in reset order. Equal reset times retain their input order. albedo_accounts.erl keeps credential publication, session affinity, transient limits, and local-time display.