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
6 changes: 6 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1055,6 +1055,12 @@ export namespace Config {
.describe("Token buffer for compaction. Leaves enough window to avoid overflow during compaction."),
})
.optional(),
fallback: z
.record(z.string(), z.string())
.optional()
.describe(
'Provider fallback map. Key is source provider ID, value is target provider ID. E.g. { "github-copilot": "amazon-bedrock" }',
),
experimental: z
.object({
disable_paste_summary: z.boolean().optional(),
Expand Down
27 changes: 25 additions & 2 deletions packages/opencode/src/provider/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ export namespace ProviderError {
return status === 404 || e.isRetryable
}

// Copilot gateway returns bare text 400s for transient issues.
// These are gateway-level rejections, not model errors, and should be retried.
// 403s are also transient — the copilot gateway sometimes returns 403 for
// rate/capacity reasons that resolve on retry or fallback.
function isCopilotErrorRetryable(e: APICallError) {
if (e.statusCode === 403) return true
if (e.statusCode === 400 && e.responseBody && !json(e.responseBody)) return true
return e.isRetryable ?? false
}

// Providers not reliably handled in this function:
// - z.ai: can accept overflow silently (needs token-count/context-window checks)
function isOverflow(message: string) {
Expand All @@ -47,8 +57,12 @@ export namespace ProviderError {
}

function message(providerID: ProviderID, e: APICallError) {
if (providerID.includes("github-copilot") && e.statusCode === 403) {
return "Please reauthenticate with the copilot provider to ensure your credentials work properly with OpenCode."
}

return iife(() => {
const msg = e.message
const msg = e.message ?? ""
if (msg === "") {
if (e.responseBody) return e.responseBody
if (e.statusCode) {
Expand All @@ -62,6 +76,13 @@ export namespace ProviderError {
return msg
}

// Avoid tautological "X: X" when response body is just the status text
const text = e.responseBody.trim()
if (e.statusCode && text.toLowerCase() === (STATUS_CODES[e.statusCode] ?? "").toLowerCase()) {
const provider = providerID.split("/")[0] ?? providerID
return `${provider} rejected the request (HTTP ${e.statusCode}). This may indicate context overflow, an unsupported request, or a gateway-level rejection.`
}

try {
const body = JSON.parse(e.responseBody)
// try to extract common error message fields
Expand Down Expand Up @@ -188,7 +209,9 @@ export namespace ProviderError {
statusCode: input.error.statusCode,
isRetryable: input.providerID.startsWith("openai")
? isOpenAiErrorRetryable(input.error)
: input.error.isRetryable,
: input.providerID.includes("github-copilot")
? isCopilotErrorRetryable(input.error)
: input.error.isRetryable,
responseHeaders: input.error.responseHeaders,
responseBody: input.error.responseBody,
metadata,
Expand Down
151 changes: 151 additions & 0 deletions packages/opencode/src/provider/fallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { APICallError, type LanguageModelMiddleware } from "ai"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Log } from "@/util/log"
import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event"

// Copilot → Bedrock model ID mapping for provider fallback.
// Bedrock inference-profile IDs use the us. cross-region prefix.
// Both us. and global. prefixes are ACTIVE per list-inference-profiles
// and confirmed working via direct invoke-model testing (2026-03-08).
export namespace ProviderFallback {
const log = Log.create({ service: "fallback" })

// sourceProvider → sourceModel → targetModel (just the model ID, no provider prefix)
const models: Record<string, Record<string, string>> = {
"github-copilot": {
"claude-sonnet-4.6": "us.anthropic.claude-sonnet-4-6",
"claude-opus-4.6": "us.anthropic.claude-opus-4-6-v1",
"claude-haiku-4.5": "us.anthropic.claude-haiku-4-5-20251001-v1:0",
},
}

// Resolves a fallback target for the given provider/model pair.
// Uses the config fallback map to find the target provider,
// then the built-in model mapping table to translate model IDs.
// Returns undefined if no fallback is configured or no model mapping exists.
export function resolve(providerID: string, modelID: string, fallback?: Record<string, string>) {
if (!fallback) return undefined
const target = fallback[providerID]
if (!target) return undefined
const mapped = models[providerID]?.[modelID]
if (!mapped) return undefined
return { providerID: target, modelID: mapped }
}

// Full overflow pattern list — mirrors error.ts OVERFLOW_PATTERNS.
// Context overflow must trigger compaction, never provider fallback.
const OVERFLOW = [
/prompt is too long/i,
/input is too long for requested model/i,
/exceeds the context window/i,
/input token count.*exceeds the maximum/i,
/maximum prompt length is \d+/i,
/reduce the length of the messages/i,
/maximum context length is \d+ tokens/i,
/exceeds the limit of \d+/i,
/exceeds the available context size/i,
/greater than the context length/i,
/context window exceeds limit/i,
/exceeded model token limit/i,
/context[_ ]length[_ ]exceeded/i,
/request entity too large/i,
/^4(00|13)\s*(status code)?\s*\(no body\)/i,
]

// Determines whether an error from the primary provider should trigger
// a fallback attempt on the secondary provider. Called inside the
// wrapStream/wrapGenerate middleware catch block with the raw error.
//
// Fallback-worthy: transient gateway/rate errors where a different
// provider likely succeeds (403, 429, 503, 500, bare-400 from Copilot),
// network failures (ECONNREFUSED, ECONNRESET, timeouts).
// 403 is included because the copilot gateway returns transient 403s
// for rate/capacity reasons; the fallback model table only maps copilot
// providers so this won't affect providers where 403 means real auth failure.
//
// NOT fallback-worthy: auth failures (401 — different provider
// has different creds, but the request shape is fine), context overflow
// (413 / overflow patterns — needs compaction, not a provider switch),
// and validation errors (prompt issues stay broken on any provider).
export function shouldFallback(err: unknown): boolean {
if (APICallError.isInstance(err)) {
const status = err.statusCode
// Auth errors — won't fix by switching provider
if (status === 401) return false
// Context overflow — needs compaction
if (status === 413) return false
if (err.message && OVERFLOW.some((p) => p.test(err.message))) return false
// Rate limits, gateway errors, and transient 403 — fallback
if (status === 429 || status === 503 || status === 500 || status === 502 || status === 403) return true
// Copilot bare-400: text/plain body, no JSON — transient rate limit
if (status === 400 && err.responseBody && !isJSON(err.responseBody)) return true
// SDK-wrapped network errors: no statusCode but marked retryable
// (AI SDK wraps ECONNREFUSED/ECONNRESET into APICallError)
if (status === undefined && err.isRetryable) return true
return false
}
// AbortSignal.timeout() fires DOMException with name "TimeoutError"
// (provider.ts applies AbortSignal.timeout on every fetch call)
if (err instanceof DOMException && err.name === "TimeoutError") return true
// Raw network failures not wrapped by AI SDK
if (err instanceof TypeError) return true
if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ECONNREFUSED") return true
if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ECONNRESET") return true
return false
}

function isJSON(input: string) {
try {
const r = JSON.parse(input)
return r && typeof r === "object"
} catch {
return false
}
}

// Creates an AI SDK middleware that attempts the primary provider,
// and on shouldFallback-worthy errors retries once on the fallback model.
// If the fallback also fails, the error propagates to the session retry loop.
export function middleware(fallback: LanguageModelV3): LanguageModelMiddleware {
return {
specificationVersion: "v3" as const,
wrapGenerate: async ({ doGenerate, params }) => {
try {
return await doGenerate()
} catch (err) {
if (!shouldFallback(err)) throw err
log.info("fallback", {
target: fallback.modelId,
error: err instanceof Error ? err.message : String(err),
status: APICallError.isInstance(err) ? err.statusCode : undefined,
})
Bus.publish(TuiEvent.ToastShow, {
title: "Provider fallback activated",
message: `Switched to ${fallback.modelId}`,
variant: "warning",
}).catch(() => {})
return await fallback.doGenerate(params)
}
},
wrapStream: async ({ doStream, params }) => {
try {
return await doStream()
} catch (err) {
if (!shouldFallback(err)) throw err
log.info("fallback", {
target: fallback.modelId,
error: err instanceof Error ? err.message : String(err),
status: APICallError.isInstance(err) ? err.statusCode : undefined,
})
Bus.publish(TuiEvent.ToastShow, {
title: "Provider fallback activated",
message: `Switched to ${fallback.modelId}`,
variant: "warning",
}).catch(() => {})
return await fallback.doStream(params)
}
},
}
}
}
15 changes: 15 additions & 0 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Provider } from "@/provider/provider"
import { ProviderID, ModelID } from "@/provider/schema"
import { Log } from "@/util/log"
import { Cause, Effect, Layer, Record, ServiceMap } from "effect"
import * as Queue from "effect/Queue"
Expand All @@ -7,6 +8,7 @@ import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, json
import { mergeDeep, pipe } from "remeda"
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
import { ProviderTransform } from "@/provider/transform"
import { ProviderFallback } from "@/provider/fallback"
import { Config } from "@/config/config"
import { Instance } from "@/project/instance"
import type { Agent } from "@/agent/agent"
Expand Down Expand Up @@ -98,6 +100,18 @@ export namespace LLM {
// TODO: move this to a proper hook
const isOpenaiOauth = provider.id === "openai" && auth?.type === "oauth"

// Resolve fallback provider if configured
const target = ProviderFallback.resolve(input.model.providerID, input.model.id, cfg.fallback)
let fallback: Awaited<ReturnType<typeof Provider.getLanguage>> | undefined
if (target) {
try {
const model = await Provider.getModel(ProviderID.make(target.providerID), ModelID.make(target.modelID))
fallback = await Provider.getLanguage(model)
} catch {
l.warn("fallback unavailable", { target: `${target.providerID}/${target.modelID}` })
}
}

const system: string[] = []
system.push(
[
Expand Down Expand Up @@ -321,6 +335,7 @@ export namespace LLM {
return args.params
},
},
...(fallback ? [ProviderFallback.middleware(fallback)] : []),
],
}),
experimental_telemetry: {
Expand Down
Loading
Loading