Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ nav_order: 10
- `MODELRELAY_HOST` (interface to listen on; same as `--host`)
- `MODELRELAY_API_JSON_LIMIT` (admin API request body limit, default `1mb`)
- `MODELRELAY_JSON_LIMIT` (`/v1` proxy request body limit, default `10mb`)
- `MODELRELAY_OPENCODE_FREE_COMPAT` (`1` enables the OpenCode Zen free-tier compatibility mode, see below)
- `MODELRELAY_OPENCODE_FREE_TIER_USER_AGENT` (overrides the User-Agent sent in that mode)

Kiro OAuth notes:
- Base endpoint is preconfigured to `https://codewhisperer.us-east-1.amazonaws.com/generateAssistantResponse`
Expand All @@ -39,6 +41,38 @@ Kiro OAuth notes:
- `KIRO_REFRESH_TOKEN`, or
- `~/.aws/sso/cache` (auto-detected refresh token), following OmniRoute’s approach.

## OpenCode Zen free-tier compatibility

OpenCode's free models only answer requests that carry the request shape of the official OpenCode
client. By default modelrelay does not imitate that client, so those models are listed as
`unsupported` and are not routed to. The reasoning and the measured gate conditions are in
[Free tier notes](free-tiers.md).

Set `opencodeFreeTierCompat` in `~/.modelrelay.json` to enable the mode:

```jsonc
{
"opencodeFreeTierCompat": true
}
```

Or set the environment variable, which takes precedence over the config file:

```bash
export MODELRELAY_OPENCODE_FREE_COMPAT=1
```

Only `1`, `true`, `yes` and `on` enable it; anything else leaves it off, so a typo cannot silently
start rewriting requests.

The mode sends a User-Agent of `opencode/1.19.3`. To use a different one — for example when that
version goes stale — set `opencodeFreeTierUserAgent` in the config file or
`MODELRELAY_OPENCODE_FREE_TIER_USER_AGENT` in the environment.

**The mode imitates the official OpenCode client, which may conflict with OpenCode's terms of
service, and it can stop working at any time.** modelrelay prints a warning at startup and shows
one in the dashboard while the mode is on. Enabling it is your decision and your responsibility.

For hosted Ollama, set `OLLAMA_API_KEY` and optionally override `OLLAMA_BASE_URL` / `OLLAMA_MODEL`.
If you leave the Ollama base URL blank in the UI, modelrelay defaults to `https://ollama.com/v1`.
With a valid Ollama API key, modelrelay will discover available Ollama models automatically.
Expand Down
2 changes: 1 addition & 1 deletion docs/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ The search box matches plain words against the model name, provider, id and tags
|---|---|
| `tag:coding` | models carrying that tag (curated or your own); `tag:coding,fast` requires both |
| `provider:groq` | provider key containing the text |
| `status:up` | exact status (`up`, `down`, `disabled`, `excluded`, `banned`, `noauth`) |
| `status:up` | exact status (`up`, `down`, `disabled`, `excluded`, `banned`, `noauth`, `unsupported`) |
| `ctx>=64k`, `ctx:64k` or `min_ctx:64k` | effective context of at least that size (`k` and `m` suffixes work) |

For example `qwen tag:coding ctx>=64k provider:groq`.
Expand Down
77 changes: 76 additions & 1 deletion docs/free-tiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,89 @@ What free tiers do in practice, measured against real providers in October 2026.
| Provider | What we saw |
|---|---|
| OpenRouter | Free models are limited to 20 requests per minute, and 50 per day until you have bought $10 of credits in total (all time); at $10 or more the daily limit is 1,000. The limit is per account, not per key, so a new key does not reset it. A key spending limit of $0 still allows `:free` models. See [OpenRouter's limits](https://openrouter.ai/docs/api-reference/limits). |
| OpenCode Zen | The free models answer `403 FreeTierError` ("the free tier can only be used from within OpenCode") for any other client. A router cannot use them with any key. |
| OpenCode Zen | The free models answer `403 FreeTierError` ("the free tier can only be used from within OpenCode") for any other client. By default modelrelay does not imitate the OpenCode client, so these models are listed as `unsupported` and are not routed to. See [OpenCode Zen free models](#opencode-zen-free-models). |
| Ollama Cloud | The free plan answers `402` for the premium models. |
| Cerebras | Answers `402` for models outside the free allowance. |
| Google AI | Free quotas are very small, or zero for some models. |
| Requesty | 200 requests per day. Most of its free models may train on prompts. |

Free models on several providers may train on the prompts you send. Do not route private code or data through them.

## OpenCode Zen free models

OpenCode's free models are not restricted by account or entitlement. They are restricted by
*request shape*: the Zen endpoint only answers requests that look like they came from the official
OpenCode client. A request that does not is refused with `403 FreeTierError` and the message
"OpenCode's free tier can only be used from within OpenCode".

**By default modelrelay respects that restriction.** The free models appear in the model list with
status `unsupported` and are never routed to. A `403` from OpenCode describes the request, not a
retired model, so it is not a "try another model" signal either — the model is alive, the request
is not what it wants.

### Opt-in compatibility mode

If you want the free models anyway, you can turn on the compatibility mode. It makes modelrelay
imitate the official OpenCode client: it sends a matching User-Agent, a session id in the format
the endpoint expects, `stream: true`, and a tool named exactly `read`.

Set `opencodeFreeTierCompat` in `~/.modelrelay.json`, or the `MODELRELAY_OPENCODE_FREE_COMPAT=1`
environment variable. The environment variable wins over the config file. See
[Configuration](configuration.md) for details.

**Read this before you enable it.** The mode:

- imitates the official OpenCode client, which may conflict with OpenCode's terms of service;
- can stop working at any time, without a notice from OpenCode;
- means that you are responsible for using it.

modelrelay prints a warning at startup and shows one in the dashboard for as long as the mode is on.

Caveat: OpenCode's Zen free models may train on prompts, have trial-only NVIDIA endpoints, and be geographically restricted.

### What the endpoint checks

Four elements are required. Each one was verified individually against the live endpoint by
changing a single element at a time:

| Element | Required value |
|---|---|
| `User-Agent` | `opencode/<version>` (any version from 1.18.0 upwards) |
| `x-opencode-session` | `ses_` + 12 lowercase hex characters + 14 base62 characters = 30 characters in total |
| `stream` | `true` |
| `tools` | a function named exactly `read` |

A `You are opencode` system message is *not* required — the request is accepted without it.

Two details are easy to get wrong:

- The session id is validated by **shape**, not by value. The 12-character hex block is a 48-bit
millisecond timestamp, and only the format is checked. A 32-character hex id, or an id of 28, 29
or 31 characters, is refused.
- The `read` tool must be named exactly `read`. A differently named function does not satisfy the
gate.

modelrelay handles the shape for you: it rewrites the request, converts the upstream's stream back
into a single JSON body for callers that asked for `stream: false`, and removes calls to the
injected `read` tool from the answer, so a caller that never declared that tool does not see an
unknown tool call in its response.

### No API key is required for the free models

The key is not what makes them work — the request shape is. Measured against the live endpoint:

| `Authorization` header | Result |
|---|---|
| absent | accepted |
| `Bearer public` | accepted |
| `Bearer ` (empty) | accepted |
| a key that is not a real OpenCode key | `500` "Invalid credential" |

A missing key is therefore harmless, but a *wrong* key is not: it fails with a server error
rather than being ignored. modelrelay sends no `Authorization` header when no OpenCode key is
configured, which is the correct behavior for the free tier. A key is still required for the paid
Zen models.

## Model ids go stale

A free variant can disappear while its slug still lists elsewhere. For example, OpenRouter answers `404` ("this model is unavailable for free") for a `:free` slug that has become paid, and names the paid slug. A model list is not proof that the model is usable, so check with a real request.
Expand Down
70 changes: 70 additions & 0 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ const PROVIDER_MODEL_ID_ENV_VARS = {
ollama: 'OLLAMA_MODEL',
}

// The User-Agent the official OpenCode client presents. Overridable, see
// getOpencodeFreeTierUserAgent.
export const DEFAULT_OPENCODE_USER_AGENT = 'opencode/1.19.3';

export function normalizeSecret(value) {
return typeof value === 'string'
? trimEndWhile(value, ch => isWhitespaceChar(ch) || (ch >= '\u2580' && ch <= '\u259F')).trim()
Expand Down Expand Up @@ -437,6 +441,67 @@ export function getPinningMode(config) {
return config?.pinningMode === 'exact' ? 'exact' : 'canonical'
}

// 📖 OpenCode Zen free-tier compatibility.
//
// OpenCode's free models are gated by the *request shape* they expect from
// the official client: a matching User-Agent, a session id in a specific
// format, `stream: true` and a tool named exactly `read`. ModelRelay does not
// imitate that client by default — the free tier is documented as a constraint
// we respect.
//
// A user who explicitly accepts that can opt in, at their own responsibility.
// The env var takes precedence over the config file, matching the other
// settings in this module.
const OPENCODE_FREE_COMPAT_ENV_VAR = 'MODELRELAY_OPENCODE_FREE_COMPAT';

// 📖 Only these spellings enable the flag. Anything else (including an empty
// string, '0', 'false' and 'no') leaves it off, so a typo cannot silently
// enable request rewriting.
const OPENCODE_FREE_COMPAT_ENABLED_VALUES = new Set(['1', 'true', 'yes', 'on']);

function parseOpencodeFreeCompatValue(value) {
if (value === true) return true;
if (typeof value !== 'string') return false;
return OPENCODE_FREE_COMPAT_ENABLED_VALUES.has(value.trim().toLowerCase());
}

/**
* 📖 isOpencodeFreeTierCompatEnabled: Is the OpenCode Zen free-tier
* compatibility mode on?
*
* Off by default. The env var wins over the config file.
*
* @param {object} config
* @returns {boolean}
*/
export function isOpencodeFreeTierCompatEnabled(config) {
const envValue = process.env[OPENCODE_FREE_COMPAT_ENV_VAR];
if (envValue !== undefined && envValue !== '') {
return parseOpencodeFreeCompatValue(envValue);
}
return parseOpencodeFreeCompatValue(config?.opencodeFreeTierCompat);
}

/**
* 📖 getOpencodeFreeTierUserAgent: The User-Agent to present to OpenCode Zen
* when the compatibility mode is on.
*
* Overridable so a stale default can be fixed without a release: set
* `opencodeFreeTierUserAgent` in the config file or
* `MODELRELAY_OPENCODE_FREE_TIER_USER_AGENT` in the environment.
*
* @param {object} config
* @returns {string}
*/
export function getOpencodeFreeTierUserAgent(config) {
const envValue = process.env.MODELRELAY_OPENCODE_FREE_TIER_USER_AGENT;
const fromEnv = normalizeText(envValue);
if (fromEnv) return fromEnv;
const fromConfig = normalizeText(config?.opencodeFreeTierUserAgent);
if (fromConfig) return fromConfig;
return DEFAULT_OPENCODE_USER_AGENT;
}

// 📖 Internal helper: create a blank config with the right shape
function _emptyConfig() {
return {
Expand Down Expand Up @@ -499,6 +564,11 @@ export function normalizeConfigShape(config) {

if (!Array.isArray(base.excludedProviders)) base.excludedProviders = []
base.pinningMode = base.pinningMode === 'exact' ? 'exact' : 'canonical'
// The OpenCode free-tier compatibility flag is a boolean. A string value is
// accepted only when it spells out an enabled state; anything else is off.
base.opencodeFreeTierCompat = parseOpencodeFreeCompatValue(base.opencodeFreeTierCompat)
if (typeof base.opencodeFreeTierUserAgent !== 'string') base.opencodeFreeTierUserAgent = ''
else base.opencodeFreeTierUserAgent = base.opencodeFreeTierUserAgent.trim()
if (!base.modelTags || typeof base.modelTags !== 'object' || Array.isArray(base.modelTags)) {
base.modelTags = {}
} else {
Expand Down
Loading
Loading