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
17 changes: 16 additions & 1 deletion .husky/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,19 @@ if (process.versions.bun !== expectedBunVersion) {
console.warn(`Warning: Bun version ${process.versions.bun} differs from expected ${expectedBunVersion}`);
}
'
bun typecheck
# tsgo crashes with OOM on Windows when checking large packages in parallel.
# Gate the typecheck so non-Windows contributors still get hard failures.
case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*)
set +e
bun typecheck
_tc=$?
set -e
if [ "$_tc" -ne 0 ]; then
echo "WARNING: typecheck exited with code $_tc on Windows (possible tsgo OOM) — continuing push" >&2
fi
;;
*)
bun typecheck
;;
esac
4 changes: 2 additions & 2 deletions packages/console/resource/resource.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const Resource = new Proxy(
keys: Array.isArray(k) ? k : [k],
account_id: accountId,
})
.then((result) => (isMulti ? new Map(Object.entries(result?.values ?? {})) : result?.values?.[k]))
.then((result: { values?: Record<string, unknown> }) => (isMulti ? new Map(Object.entries(result?.values ?? {})) : result?.values?.[k]))
},
put: (k: string, v: string, opts?: KVNamespacePutOptions) =>
client.kv.namespaces.values.update(namespaceId, k, {
Expand All @@ -55,7 +55,7 @@ export const Resource = new Proxy(
account_id: accountId,
prefix: opts?.prefix ?? undefined,
})
.then((result) => {
.then((result: { result: unknown[] }) => {
return {
keys: result.result,
list_complete: true,
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/v1/config/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ export const Model = Schema.Struct({
),
).annotate({ description: "Variant-specific configuration" }),
),
fallback: Schema.optional(
Schema.mutable(Schema.Array(Schema.String)).annotate({
description: "Ordered list of fallback models (provider/model-id) tried when this model fails with a transient error",
}),
),
})

export const Info = Schema.Struct({
Expand Down
58 changes: 58 additions & 0 deletions packages/opencode/src/provider/fallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { ProviderError } from "./error"

type NamedErrorObject = {
name: string
data: {
statusCode?: number
isRetryable?: boolean
message?: string
}
}

export function shouldFallback(error: NamedErrorObject | Error): boolean {
if (error instanceof ProviderError.HeaderTimeoutError) return true
if (error instanceof ProviderError.ResponseStreamError) return true
if (!("name" in error)) return false
if (error.name === "ContextOverflowError") return false
if (error.name === "ProviderAuthError") return false
if (error.name === "APIError") {
const data = (error as NamedErrorObject).data
const status = data?.statusCode
if (status === 401) return false
if (status === 413) return false
// 404 is fallback-worthy: for OpenAI-compatible providers it usually means
// model-not-found (the model was retired or misconfigured), so falling back
// to the next model is correct. A wrong base URL also returns 404, but that
// is a config error that should be fixed at the provider level, not here.
if (status === 429 || status === 500 || status === 502 || status === 503 || status === 404) return true
if (status === undefined && data?.isRetryable) return true
return false
}
return false
}

export function resolveFallback(
current: { providerID: string; modelID: string },
config: ConfigV1.Info,
tried: Set<string> = new Set(),
): { providerID: string; modelID: string } | undefined {
// Note: does not validate that the target provider/model exists in config.
// A non-existent target is logged as a warning in the processor and treated
// as "no fallback available". Validating against the provider list would
// require a runtime check; for now, config errors surface as warnings.
const provider = config.provider?.[current.providerID]
if (!provider?.models) return undefined
const model = provider.models[current.modelID]
if (!model?.fallback) return undefined
const entry = model.fallback.find((e) => {
if (tried.has(e)) return false
const slash = e.indexOf("/")
return slash > 0 && slash < e.length - 1
})
if (!entry) return undefined
const slash = entry.indexOf("/")
return { providerID: entry.slice(0, slash), modelID: entry.slice(slash + 1) }
}

export * as ProviderFallback from "./fallback"
76 changes: 74 additions & 2 deletions packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ import type { SessionID } from "./schema"
import { SessionRetry } from "./retry"
import { SessionStatus } from "./status"
import { SessionSummary } from "./summary"
import type { Provider } from "@/provider/provider"
import { Provider } from "@/provider/provider"
import { ProviderFallback } from "@/provider/fallback"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { Question } from "@/question"
import { errorMessage } from "@/util/error"
import { isRecord } from "@/util/record"
Expand Down Expand Up @@ -94,6 +97,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) {
// Pre-capture snapshot before the LLM stream starts. The AI SDK
Expand Down Expand Up @@ -638,6 +642,73 @@ const layer = Layer.effect(
yield* status.set(ctx.sessionID, { type: "idle" })
})

const triedFallbacks = new Set<string>()

const attemptFallback = Effect.fn("SessionProcessor.attemptFallback")(function* (streamInput: LLM.StreamInput, err: unknown) {
const parsed = parse(err)
if (!ProviderFallback.shouldFallback(parsed)) {
yield* halt(err)
return
}
const cfg = yield* config.get()
const next = ProviderFallback.resolveFallback(
{ providerID: input.model.providerID, modelID: input.model.id },
cfg,
triedFallbacks,
)
if (!next) {
yield* halt(err)
return
}
const key = `${next.providerID}/${next.modelID}`
triedFallbacks.add(key)
yield* Effect.logInfo("model fallback", { from: `${input.model.providerID}/${input.model.id}`, to: key })
const fallbackModel = yield* provider.getModel(
ProviderV2.ID.make(next.providerID),
ModelV2.ID.make(next.modelID),
).pipe(
Effect.catch((e) => Effect.gen(function* () {
yield* Effect.logWarning("fallback model not found", { target: key, error: errorMessage(e) })
return yield* Effect.fail(err)
})),
)
const fallbackInput = { ...streamInput, model: fallbackModel }
yield* Effect.gen(function* () {
ctx.currentText = undefined
ctx.reasoningMap = {}
ctx.toolcalls = {}
ctx.needsCompaction = false
yield* status.set(ctx.sessionID, { type: "busy" })
const stream = llm.stream(fallbackInput)
yield* stream.pipe(
Stream.tap((event) => handleEvent(event)),
Stream.takeUntil(() => ctx.needsCompaction),
Stream.runDrain,
)
}).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => Effect.fail(Cause.squash(cause)),
),
Effect.retry(
SessionRetry.policy({
provider: next.providerID,
parse,
set: (info) => {
return status.set(ctx.sessionID, {
type: "retry",
attempt: info.attempt,
message: info.message,
action: info.action,
next: info.next,
})
},
}),
),
Effect.catch(halt),
)
})

const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) {
yield* Effect.logInfo("process", {
"session.id": input.sessionID,
Expand Down Expand Up @@ -686,7 +757,7 @@ const layer = Layer.effect(
},
}),
),
Effect.catch(halt),
Effect.catch((err) => Effect.gen(function* () { yield* attemptFallback(streamInput, err) }).pipe(Effect.catch(halt))),
Effect.ensuring(cleanup()),
)

Expand Down Expand Up @@ -726,6 +797,7 @@ export const node = LayerNode.make({
Image.node,
EventV2Bridge.node,
Database.node,
Provider.node,
],
})

Expand Down
39 changes: 39 additions & 0 deletions packages/opencode/test/config/provider-schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { ConfigProviderV1 } from "@opencode-ai/core/v1/config/provider"

const Model = ConfigProviderV1.Model
const Info = ConfigProviderV1.Info

describe("ConfigProviderV1.Model.fallback", () => {
test("fallback is optional and undefined by default", () => {
const result = Schema.decodeUnknownSync(Model)({})
expect(result.fallback).toBeUndefined()
})

test("fallback accepts an array of strings", () => {
const result = Schema.decodeUnknownSync(Model)({
fallback: ["openai/gpt-5", "anthropic/claude-sonnet-4"],
})
expect(result.fallback).toEqual(["openai/gpt-5", "anthropic/claude-sonnet-4"])
})

test("fallback rejects non-string entries", () => {
expect(() => Schema.decodeUnknownSync(Model)({ fallback: ["openai/gpt-5", 123] })).toThrow()
})

test("fallback rejects non-array value", () => {
expect(() => Schema.decodeUnknownSync(Model)({ fallback: "openai/gpt-5" })).toThrow()
})

test("fallback is accessible through provider config", () => {
const result = Schema.decodeUnknownSync(Info)({
models: {
"claude-sonnet-4": {
fallback: ["openai/gpt-5"],
},
},
})
expect(result.models?.["claude-sonnet-4"]?.fallback).toEqual(["openai/gpt-5"])
})
})
Loading
Loading