Skip to content
Open
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
49 changes: 43 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,19 @@ The plugin tracks model health globally, keeps fallback depth per session, and w

## Installation

Add the plugin to `~/.config/opencode/opencode.jsonc`:
Add the plugin to `~/.config/opencode/opencode.json`:

```jsonc
```json
{
"plugin": ["@smart-coders-hq/opencode-model-fallback"],
"plugin": ["@smart-coders-hq/opencode-model-fallback"]
}
```

For local development:

```jsonc
```json
{
"plugin": ["file:///path/to/opencode-model-fallback/dist/index.js"],
"plugin": ["file:///path/to/opencode-model-fallback/dist/index.js"]
}
```

Expand All @@ -46,6 +46,12 @@ Create `model-fallback.json` in either:
- `.opencode/model-fallback.json`
- `~/.config/opencode/model-fallback.json`

Model keys use the format `providerID/modelID`. Nested provider paths with forward slashes are supported:

- `anthropic/claude-sonnet-4-20250514`
- `nvidia/z-ai/glm-5.2`
- `openrouter/tencent/hy3:free`

Minimal example:

```json
Expand Down Expand Up @@ -113,6 +119,37 @@ Per-agent example:

If you still have `rate-limit-fallback.json`, it is discovered and auto-migrated on load.

## Extended `fallbackOn` categories

The 5 default categories (`rate_limit`, `quota_exceeded`, `5xx`, `timeout`, `overloaded`) cover common provider errors. For users on free tiers or providers with aggressive billing checks, an expanded set of 29 categories catches additional failure modes:

| Category | Reason |
|----------|--------|
| `unauthorized`, `401`, `402`, `403` | Auth/credit errors not covered by defaults |
| `429`, `500`, `502`, `503`, `504` | Explicit HTTP status codes |
| `limit`, `insufficient_credits`, `insufficient_balance` | Free tier exhausted messages |
| `insufficient_quota`, `premium_subscription`, `premium` | Paywall barriers |
| `subscription_required`, `payment_required` | Free plan errors |
| `credit_balance`, `quota_exceeded_message`, `billing` | Billing errors |
| `no_credits`, `out_of_credits`, `insufficient_funds` | No balance |

Example with expanded categories:

```json
{
"defaults": {
"fallbackOn": [
"rate_limit", "quota_exceeded", "5xx", "timeout", "overloaded",
"unauthorized", "401", "402", "403", "429", "500", "502", "503", "504",
"limit", "insufficient_credits", "insufficient_balance", "insufficient_quota",
"premium_subscription", "premium", "subscription_required", "payment_required",
"credit_balance", "quota_exceeded_message", "billing", "no_credits",
"out_of_credits", "insufficient_funds"
]
}
}
```

## `/fallback-status`

Run `/fallback-status` in any OpenCode session to see:
Expand Down Expand Up @@ -158,7 +195,7 @@ bun run build

Local plugin config:

```jsonc
```json
{ "plugin": ["file:///absolute/path/to/dist/index.js"] }
```

Expand Down
2 changes: 1 addition & 1 deletion src/config/agent-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { homedir } from "os";
import { basename, extname, isAbsolute, join, relative, resolve } from "path";
import type { AgentConfig } from "../types.js";

const MODEL_KEY_RE = /^[a-zA-Z0-9_-]{1,100}\/[a-zA-Z0-9._-]{1,100}$/;
const MODEL_KEY_RE = /^[a-zA-Z0-9_-]{1,100}\/[a-zA-Z0-9._\/:-]{1,100}$/;

function isPathInside(baseDir: string, targetPath: string): boolean {
const rel = relative(baseDir, targetPath);
Expand Down
9 changes: 8 additions & 1 deletion src/config/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,14 @@ export const DEFAULT_LOG_PATH = join(homedir(), ".local/share/opencode/logs/mode
export const DEFAULT_CONFIG: PluginConfig = {
enabled: true,
defaults: {
fallbackOn: ["rate_limit", "quota_exceeded", "5xx", "timeout", "overloaded"],
fallbackOn: [
"rate_limit", "quota_exceeded", "5xx", "timeout", "overloaded",
"unauthorized", "401", "402", "403", "429", "500", "502", "503", "504",
"limit", "insufficient_credits", "insufficient_balance", "insufficient_quota",
"premium_subscription", "premium", "subscription_required", "payment_required",
"credit_balance", "quota_exceeded_message", "billing", "no_credits",
"out_of_credits", "insufficient_funds"
],
cooldownMs: 300_000, // 5 minutes
retryOriginalAfterMs: 900_000, // 15 minutes
maxFallbackDepth: 3,
Expand Down
4 changes: 2 additions & 2 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { isAbsolute, relative, resolve } from "path";
import { z } from "zod";
import { DEFAULT_CONFIG } from "./defaults.js";

const MODEL_KEY_RE = /^[a-zA-Z0-9_-]{1,100}\/[a-zA-Z0-9._-]{1,100}$/;
const MODEL_KEY_RE = /^[a-zA-Z0-9_-]{1,100}\/[a-zA-Z0-9._\/:-]{1,100}$/;
const home = resolve(homedir());

function formatPath(path: Array<PropertyKey>): string {
Expand All @@ -30,7 +30,7 @@ const agentConfig = z.object({

const fallbackDefaults = z.object({
fallbackOn: z
.array(z.enum(["rate_limit", "quota_exceeded", "5xx", "timeout", "overloaded"]))
.array(z.enum(["rate_limit", "quota_exceeded", "5xx", "timeout", "overloaded", "unauthorized", "401", "402", "403", "429", "500", "502", "503", "504", "limit", "insufficient_credits", "insufficient_balance", "insufficient_quota", "premium_subscription", "premium", "subscription_required", "payment_required", "credit_balance", "quota_exceeded_message", "billing", "no_credits", "out_of_credits", "insufficient_funds"]))
.optional(),
cooldownMs: z.number().min(10_000).optional(),
retryOriginalAfterMs: z.number().min(10_000).optional(),
Expand Down
99 changes: 76 additions & 23 deletions src/detection/classifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ const RATE_LIMIT_PATTERNS = [
"usage limit",
"resource exhausted",
"resource_exhausted",
"429",
];

const QUOTA_PATTERNS = [
Expand Down Expand Up @@ -36,32 +35,86 @@ const SERVER_ERROR_PATTERNS = [
"bad gateway",
"service unavailable",
"gateway timeout",
"500",
"502",
"503",
"504",
];

const UNAUTHORIZED_PATTERNS = [
"unauthorized",
"authentication required",
"invalid api key",
"access denied",
];

const LIMIT_PATTERNS = [
"limit reached",
"usage limit exceeded",
"request limit",
];

const INSUFFICIENT_CREDITS_PATTERNS = [
"insufficient credits",
"insufficient credit",
"no credits",
];

const INSUFFICIENT_BALANCE_PATTERNS = [
"insufficient balance",
"insufficient funds",
"balance too low",
"account balance",
];

const PREMIUM_PATTERNS = [
"premium subscription",
"premium required",
"subscription required",
"paid subscription",
];

const PAYMENT_PATTERNS = [
"payment required",
"billing required",
"upgrade required",
];

const CREDIT_BALANCE_PATTERNS = [
"credit balance",
"credit too low",
"credit usage",
];

const BILLING_PATTERNS = [
"billing error",
"billing issue",
"billing problem",
];

export function classifyError(message: string, statusCode?: number): ErrorCategory {
const text = message.toLowerCase();

if (statusCode === 429 || matchesAnyPattern(text, RATE_LIMIT_PATTERNS)) {
return "rate_limit";
}
if (statusCode === 402 || matchesAnyPattern(text, QUOTA_PATTERNS)) {
return "quota_exceeded";
}
if (statusCode === 529 || matchesAnyPattern(text, OVERLOADED_PATTERNS)) {
return "overloaded";
}
if (matchesAnyPattern(text, TIMEOUT_PATTERNS)) {
return "timeout";
}
if (
(statusCode !== undefined && statusCode >= 500) ||
matchesAnyPattern(text, SERVER_ERROR_PATTERNS)
) {
return "5xx";
}
// Status code based classification (preserves original behavior)
if (statusCode === 429) return "rate_limit";
if (statusCode === 402) return "quota_exceeded";
if (statusCode === 529) return "overloaded";
if (statusCode === 401) return "unauthorized";
if (statusCode === 403) return "unauthorized";

// Message pattern based classification
if (matchesAnyPattern(text, UNAUTHORIZED_PATTERNS)) return "unauthorized";
if (matchesAnyPattern(text, RATE_LIMIT_PATTERNS)) return "rate_limit";
if (matchesAnyPattern(text, QUOTA_PATTERNS)) return "quota_exceeded";
if (matchesAnyPattern(text, INSUFFICIENT_CREDITS_PATTERNS)) return "insufficient_credits";
if (matchesAnyPattern(text, INSUFFICIENT_BALANCE_PATTERNS)) return "insufficient_balance";
if (matchesAnyPattern(text, PREMIUM_PATTERNS)) return "premium";
if (matchesAnyPattern(text, PAYMENT_PATTERNS)) return "payment_required";
if (matchesAnyPattern(text, CREDIT_BALANCE_PATTERNS)) return "credit_balance";
if (matchesAnyPattern(text, BILLING_PATTERNS)) return "billing";
if (matchesAnyPattern(text, LIMIT_PATTERNS)) return "limit";
if (matchesAnyPattern(text, OVERLOADED_PATTERNS)) return "overloaded";
if (matchesAnyPattern(text, TIMEOUT_PATTERNS)) return "timeout";
if (matchesAnyPattern(text, SERVER_ERROR_PATTERNS)) return "5xx";

// Generic status code ranges
if (statusCode !== undefined && statusCode >= 500) return "5xx";

return "unknown";
}
23 changes: 23 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,29 @@ export type ErrorCategory =
| "5xx"
| "timeout"
| "overloaded"
| "unauthorized"
| "401"
| "402"
| "403"
| "429"
| "500"
| "502"
| "503"
| "504"
| "limit"
| "insufficient_credits"
| "insufficient_balance"
| "insufficient_quota"
| "premium_subscription"
| "premium"
| "subscription_required"
| "payment_required"
| "credit_balance"
| "quota_exceeded_message"
| "billing"
| "no_credits"
| "out_of_credits"
| "insufficient_funds"
| "unknown";

export type HealthState = "healthy" | "rate_limited" | "cooldown";
Expand Down