Skip to content
Draft
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
4 changes: 4 additions & 0 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,9 +362,13 @@ function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage

if (shouldUseContentOptions) {
const lastContent = msg.content[msg.content.length - 1]
// Anthropic rejects cache_control on empty text blocks, so an empty tail
// part falls through to the message-level marker.
const emptyText = typeof lastContent === "object" && lastContent.type === "text" && !lastContent.text
if (
lastContent &&
typeof lastContent === "object" &&
!emptyText &&
lastContent.type !== "tool-approval-request" &&
lastContent.type !== "tool-approval-response"
) {
Expand Down
120 changes: 120 additions & 0 deletions packages/opencode/src/session/fallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Schema } from "effect"
import { isRecord } from "@/util/record"
import type { Err } from "./retry"

// The coordinator ships this per sandbox; it is the same shape the removed
// model-fallback plugin consumed, so nothing upstream has to change.
export const ENV_VAR = "REPLO_OPENCODE_FALLBACK_CONFIG"

const ConfigSchema = Schema.Struct({
fallbackModelsByModel: Schema.Record(Schema.String, Schema.String),
fallbackOnErrors: Schema.Array(Schema.Number),
maxFallbackAttempts: Schema.Number,
maxUpstreamRetryAttempts: Schema.Number,
cooldownSeconds: Schema.Number,
})
export type Config = Schema.Schema.Type<typeof ConfigSchema>

export type ModelRef = { providerID: string; modelID: string }

const NETWORK_ERROR_PATTERN =
/fetch failed|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|socket hang up|network error|terminated/i

let loaded = false
let current: Config | null = null
const degradedUntil = new Map<string, number>()

export function config(): Config | null {
if (loaded) return current
loaded = true
const raw = process.env[ENV_VAR]
if (!raw) return null
try {
current = Schema.decodeUnknownSync(ConfigSchema)(JSON.parse(raw))
} catch (error) {
console.error("[model-fallback] ignoring invalid config", error)
}
return current
}

// Tests inject a config instead of setting the env var.
export function configure(next: Config | null) {
loaded = true
current = next
degradedUntil.clear()
}

export function reset() {
loaded = false
current = null
degradedUntil.clear()
}

export function key(model: ModelRef) {
return `${model.providerID}/${model.modelID}`
}

export function parse(value: string): ModelRef {
const index = value.indexOf("/")
return { providerID: value.slice(0, index), modelID: value.slice(index + 1) }
}

export function fallbackFor(model: ModelRef): ModelRef | undefined {
const target = config()?.fallbackModelsByModel[key(model)]
return target ? parse(target) : undefined
}

export function markDegraded(providerID: string, now = Date.now()) {
const cfg = config()
if (!cfg) return
degradedUntil.set(providerID, now + cfg.cooldownSeconds * 1000)
}

export function isDegraded(providerID: string, now = Date.now()) {
const until = degradedUntil.get(providerID)
if (until === undefined) return false
if (until > now) return true
degradedUntil.delete(providerID)
return false
}

// Follows the fallback chain past every provider that is cooling down, so the
// steps after a failure start on the route that just worked.
export function healthy(model: ModelRef, now = Date.now()): ModelRef {
const seen = new Set<string>([key(model)])
let candidate = model
while (isDegraded(candidate.providerID, now)) {
const next = fallbackFor(candidate)
if (!next || seen.has(key(next))) return model
seen.add(key(next))
candidate = next
}
return candidate
}

// Transport-level failures the fallback config lists, plus errors with no
// status at all (the request never reached a provider). Context overflow and
// aborts are the caller's problem and never switch models.
export function qualifies(error: Err): boolean {
const cfg = config()
if (!cfg) return false
if (SessionV1.ContextOverflowError.isInstance(error)) return false
if (SessionV1.AbortedError.isInstance(error)) return false
if (SessionV1.APIError.isInstance(error)) {
const status = error.data.statusCode
if (status === undefined) return true
return cfg.fallbackOnErrors.includes(status) || status === 402
}
const message = isRecord(error.data) ? error.data.message : undefined
return typeof message === "string" && NETWORK_ERROR_PATTERN.test(message)
}

export function next(input: { model: ModelRef; error: Err; swaps: number }): ModelRef | undefined {
const cfg = config()
if (!cfg || input.swaps >= cfg.maxFallbackAttempts) return undefined
if (!qualifies(input.error)) return undefined
return fallbackFor(input.model)
}

export * as SessionFallback from "./fallback"
46 changes: 41 additions & 5 deletions packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { Image } from "@/image/image"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Context, Scope, Schema } from "effect"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Context, Option, Scope, Schema } from "effect"
import * as Stream from "effect/Stream"
import { Agent } from "@/agent/agent"
import { Config } from "@/config/config"
Expand All @@ -17,9 +17,12 @@ import { isOverflow } from "./overflow"
import { PartID } from "./schema"
import type { SessionID } from "./schema"
import { SessionRetry } from "./retry"
import { SessionFallback } from "./fallback"
import { SessionStatus } from "./status"
import { SessionSummary } from "./summary"
import type { Provider } from "@/provider/provider"
import { Provider } from "@/provider/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Question } from "@/question"
import { errorMessage } from "@/util/error"
import { isRecord } from "@/util/record"
Expand Down Expand Up @@ -82,6 +85,7 @@ interface ProcessorContext extends Input {
firstRequestStartAt: number | undefined
snapshotMs: number
reasoningMap: Record<string, SessionV1.ReasoningPart>
fallbacks: number
}

type StreamEvent = LLMEvent
Expand All @@ -104,6 +108,7 @@ const layer = Layer.effect(
const image = yield* Image.Service
const events = yield* EventV2Bridge.Service
const database = yield* Database.Service
const provider = yield* Provider.Service

const create = Effect.fn("SessionProcessor.create")(function* (input: Input) {
const ctx: ProcessorContext = {
Expand All @@ -121,6 +126,7 @@ const layer = Layer.effect(
requestStartAt: undefined,
firstRequestStartAt: undefined,
reasoningMap: {},
fallbacks: 0,
}
let aborted = false

Expand Down Expand Up @@ -152,10 +158,37 @@ const layer = Layer.effect(

const parse = (e: unknown) =>
MessageV2.fromError(e, {
providerID: input.model.providerID,
providerID: ctx.model.providerID,
aborted,
})

// Swaps the step onto the configured fallback model and records the
// switch on the assistant message, so the persisted row names the model
// that actually answered.
const fallback = (error: SessionRetry.Err) =>
Effect.gen(function* () {
const from = { providerID: ctx.model.providerID, modelID: ctx.model.id }
const target = SessionFallback.next({ model: from, error, swaps: ctx.fallbacks })
if (!target) return undefined
const resolved = yield* provider
.getModel(ProviderV2.ID.make(target.providerID), ModelV2.ID.make(target.modelID))
.pipe(Effect.option)
if (Option.isNone(resolved)) return undefined
SessionFallback.markDegraded(from.providerID)
ctx.model = resolved.value
ctx.fallbacks += 1
ctx.assistantMessage.providerID = resolved.value.providerID
ctx.assistantMessage.modelID = resolved.value.id
yield* session.updateMessage(ctx.assistantMessage)
yield* Effect.logWarning("[model-fallback] switched model after provider failure", {
"session.id": ctx.sessionID,
from: SessionFallback.key(from),
to: SessionFallback.key(target),
error: error.data,
})
return { message: `${from.providerID} unavailable, continuing on ${target.providerID}/${target.modelID}` }
})

const settleToolCall = Effect.fn("SessionProcessor.settleToolCall")(function* (toolCallID: string) {
const done = ctx.toolcalls[toolCallID]?.done
delete ctx.toolcalls[toolCallID]
Expand Down Expand Up @@ -678,7 +711,7 @@ const layer = Layer.effect(
ctx.requestStartAt = Date.now()
// Retries re-stamp requestStartAt; keep the first attempt so prep_ms excludes backoff.
if (ctx.firstRequestStartAt === undefined) ctx.firstRequestStartAt = ctx.requestStartAt
const stream = llm.stream(streamInput)
const stream = llm.stream({ ...streamInput, model: ctx.model })

yield* stream.pipe(
Stream.tap((event) => handleEvent(event)),
Expand All @@ -700,7 +733,9 @@ const layer = Layer.effect(
),
Effect.retry(
SessionRetry.policy({
provider: input.model.providerID,
provider: () => ctx.model.providerID,
attempts: SessionFallback.config()?.maxUpstreamRetryAttempts,
fallback,
parse,
set: (info) => {
return status.set(ctx.sessionID, {
Expand Down Expand Up @@ -766,6 +801,7 @@ export const node = LayerNode.make({
Image.node,
EventV2Bridge.node,
Database.node,
Provider.node,
],
})

Expand Down
8 changes: 7 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { Tool } from "@/tool/tool"
import { Permission } from "@/permission"
import { Question } from "@/question"
import { SessionStatus } from "./status"
import { SessionFallback } from "./fallback"
import { LLM } from "./llm"
import { Shell } from "@opencode-ai/core/shell"
import { ShellID } from "@/tool/shell/id"
Expand Down Expand Up @@ -1181,7 +1182,12 @@ const layer = Layer.effect(
history: msgs,
}).pipe(Effect.ignore, Effect.forkIn(scope))

const model = yield* getModel(lastUser.model.providerID, lastUser.model.modelID, sessionID)
const requested = yield* getModel(lastUser.model.providerID, lastUser.model.modelID, sessionID)
const route = SessionFallback.healthy({ providerID: requested.providerID, modelID: requested.id })
const model =
route.providerID === requested.providerID && route.modelID === requested.id
? requested
: yield* getModel(ProviderV2.ID.make(route.providerID), ModelV2.ID.make(route.modelID), sessionID)
const task = tasks.pop()

if (task?.type === "subtask") {
Expand Down
39 changes: 28 additions & 11 deletions packages/opencode/src/session/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,25 +174,42 @@ function parseJSON(value: unknown) {
}

export function policy(opts: {
provider: string
provider: string | (() => string)
parse: (error: unknown) => Err
set: (input: { attempt: number; message: string; action?: Retryable["action"]; next: number }) => Effect.Effect<void>
// Same-provider retries stop after this many attempts; unset keeps today's unbounded behaviour.
attempts?: number
// Asked once retries are exhausted or the error is not retryable. Returning a
// message means the caller swapped models and the schedule should continue at once.
fallback?: (error: Err, attempt: number) => Effect.Effect<Retryable | undefined>
}) {
return Schedule.fromStepWithMetadata(
Effect.succeed((meta: Schedule.InputMetadata<unknown>) => {
const error = opts.parse(meta.input)
const retry = retryable(error, opts.provider)
if (!retry) return Cause.done(meta.attempt)
const provider = typeof opts.provider === "function" ? opts.provider() : opts.provider
const retry = retryable(error, provider)
const exhausted = opts.attempts !== undefined && meta.attempt > opts.attempts
if (retry && !exhausted) {
return Effect.gen(function* () {
const wait = delay(meta.attempt, SessionV1.APIError.isInstance(error) ? error : undefined)
const now = yield* Clock.currentTimeMillis
yield* opts.set({
attempt: meta.attempt,
message: retry.message,
action: retry.action,
next: now + wait,
})
return [meta.attempt, Duration.millis(wait)] as [number, Duration.Duration]
})
}
const fallback = opts.fallback
if (!fallback) return Cause.done(meta.attempt)
return Effect.gen(function* () {
const wait = delay(meta.attempt, SessionV1.APIError.isInstance(error) ? error : undefined)
const swapped = yield* fallback(error, meta.attempt)
if (!swapped) return yield* Cause.done(meta.attempt)
const now = yield* Clock.currentTimeMillis
yield* opts.set({
attempt: meta.attempt,
message: retry.message,
action: retry.action,
next: now + wait,
})
return [meta.attempt, Duration.millis(wait)] as [number, Duration.Duration]
yield* opts.set({ attempt: meta.attempt, message: swapped.message, action: swapped.action, next: now })
return [meta.attempt, Duration.zero] as [number, Duration.Duration]
})
}),
)
Expand Down
Loading
Loading