Skip to content
Closed
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
5 changes: 4 additions & 1 deletion packages/core/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * as AgentV2 from "./agent"
import { makeLocationNode } from "./effect/app-node"
import { Array, Context, Effect, Layer, Types } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { Model } from "@opencode-ai/schema/model"
import { EventV2 } from "./event"
import { State } from "./state"

Expand All @@ -13,7 +14,9 @@ export const defaultID = ID.make("build")
export const Color = Agent.Color

export const Info = Agent.Info
export type Info = Agent.Info
export interface Info extends Agent.Info {
readonly fallback?: ReadonlyArray<Model.Ref>
}

export const Event = Agent.Event

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/config/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const Color = Schema.Union([

export class Info extends Schema.Class<Info>("ConfigV2.Agent")({
model: Schema.String.pipe(Schema.optional),
fallback: Schema.Array(Schema.String).pipe(Schema.optional),
variant: Schema.String.pipe(Schema.optional),
request: ConfigProvider.Request.pipe(Schema.optional),
system: Schema.String.pipe(Schema.optional),
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/config/plugin/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info)
const decodeConfig = Schema.decodeUnknownOption(Config.Info)
const agentKeys = new Set([
"model",
"fallback",
"variant",
"request",
"system",
Expand Down Expand Up @@ -78,6 +79,14 @@ export const Plugin = define({
const model = ModelV2.parse(item.model)
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
}
if (item.fallback !== undefined) {
Object.assign(agent, {
fallback: item.fallback.map((ref) => {
const parsed = ModelV2.parse(ref)
return { id: parsed.modelID, providerID: parsed.providerID }
}),
})
}
if (item.variant !== undefined && agent.model !== undefined) {
agent.model.variant = ModelV2.VariantID.make(item.variant)
}
Expand Down
78 changes: 76 additions & 2 deletions packages/core/src/session/runner/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import { SessionSchema } from "../schema"
import { SessionStore } from "../store"
import { SessionTitle } from "../title"
import { Service } from "./index"
import { ModelV2 } from "../../model"
import { SessionMessage } from "../message"
import { SessionRunnerModel } from "./model"
import { createLLMEventPublisher } from "./publish-llm-event"
import { toLLMMessages } from "./to-llm-message"
Expand Down Expand Up @@ -165,6 +167,38 @@ const layer = Layer.effect(
{ concurrency: "unbounded" },
).pipe(Effect.map(SystemContext.combine))

const fallbackMessagePattern =
/\bunknown model\b|\b(model|deployment)\b.*\b(not found|not available|unavailable|does not exist|unsupported|access denied|insufficient permissions?)\b/i

// Only model-unavailability failures should switch agents to a fallback model.
const isFallbackTrigger = (error: LLMError): boolean => {
const reason = error.reason
if (reason._tag === "RateLimit" || reason._tag === "QuotaExceeded" || reason._tag === "ProviderInternal")
return true
if (reason._tag === "InvalidRequest") {
if (reason.classification === "context-overflow") return false
if (reason.http?.response?.status === 404) return true
return fallbackMessagePattern.test(reason.http?.body ?? reason.message)
}
if (reason._tag === "Authentication") return fallbackMessagePattern.test(reason.http?.body ?? reason.message)
return false
}

// Finds the next fallback ref after the current model in the agent's fallback list.
// When the current model is not in the fallback list (e.g. the primary model),
// returns the first fallback entry.
const getNextFallback = (
current: ModelV2.Ref | undefined,
fallback: readonly ModelV2.Ref[],
): ModelV2.Ref | undefined => {
if (!current) return fallback[0]
const idx = fallback.findIndex(
(f) => f.providerID === current.providerID && f.id === current.id,
)
if (idx >= 0) return idx < fallback.length - 1 ? fallback[idx + 1] : undefined
return fallback[0]
}

const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* (
sessionID: SessionSchema.ID,
promotion: SessionInput.Delivery | undefined,
Expand Down Expand Up @@ -195,7 +229,29 @@ const layer = Layer.effect(
}
if (promoted > 0) currentStep = 1
}
const resolved = yield* models.resolve(session)
const fallbackList = agent.info?.fallback ?? []
const resolved = yield* fallbackList.length
? Effect.gen(function* () {
const direct = yield* models.resolve(session).pipe(Effect.option)
if (Option.isSome(direct)) return direct.value
for (const ref of fallbackList) {
if (session.model && session.model.providerID === ref.providerID && session.model.id === ref.id) continue
const result = yield* models.resolve({ ...session, model: ref }).pipe(Effect.option)
if (Option.isSome(result)) {
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID: session.id,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
model: ref,
})
return result.value
}
}
return yield* Effect.fail(
new SessionRunnerModel.AllFallbacksExhaustedError({ sessionID: session.id }),
)
})
: models.resolve(session)
const model = resolved.model
const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq)
const context = entries.map((entry) => entry.message)
Expand Down Expand Up @@ -330,7 +386,14 @@ const layer = Layer.effect(
// error was already recorded from the stream.
if (overflowFailure) yield* publish(overflowFailure)
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
if (llmFailure && !publisher.hasProviderError()) {
const nextFallback =
stream._tag === "Failure" &&
streamFailure instanceof LLMError &&
!publisher.hasAssistantStarted() &&
isFallbackTrigger(streamFailure)
? getNextFallback(resolved.ref, fallbackList)
: undefined
if (llmFailure && !publisher.hasProviderError() && !nextFallback) {
yield* serialized(publisher.failUnsettledTools("Provider did not return a tool result", true))
yield* serialized(publisher.failAssistant(llmFailure.reason.message))
}
Expand Down Expand Up @@ -375,6 +438,17 @@ const layer = Layer.effect(
if (stream._tag === "Success" && !providerFailed)
yield* serialized(publisher.failUnsettledTools("Provider did not return a tool result", true))

// Fallback on model-related provider failure before terminal failure.
// Only attempt when no assistant output was started, similar to overflow recovery.
if (nextFallback) {
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID: session.id,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
model: nextFallback,
})
return { _tag: "RestartAfterFallback", step: currentStep } as const
}
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
if (settled._tag === "Failure" && (toolsInterrupted || infraError !== undefined))
return yield* Effect.failCause(settled.cause)
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/session/runner/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ export class ModelNotSelectedError extends Schema.TaggedErrorClass<ModelNotSelec
}
}

export class AllFallbacksExhaustedError extends Schema.TaggedErrorClass<AllFallbacksExhaustedError>()(
"SessionRunnerModel.AllFallbacksExhaustedError",
{
sessionID: SessionSchema.ID,
},
) {
override get message() {
return `All fallback models exhausted for session ${this.sessionID}`
}
}

export class ModelUnavailableError extends Schema.TaggedErrorClass<ModelUnavailableError>()(
"SessionRunnerModel.ModelUnavailableError",
{
Expand Down Expand Up @@ -70,6 +81,7 @@ export type Error =
| ModelUnavailableError
| VariantUnavailableError
| UnsupportedApiError
| AllFallbacksExhaustedError
| Integration.AuthorizationError

export interface Resolved {
Expand Down
Loading
Loading