Compare commits
1 Commits
fix/issue-
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5073cc6b15 |
@@ -1,131 +0,0 @@
|
||||
import type { OhMyOpenCodeConfig, HookName } from "../../config"
|
||||
|
||||
import { createModelFallbackHook } from "../../hooks"
|
||||
import { normalizeSDKResponse } from "../../shared"
|
||||
|
||||
import { resolveModelFallbackEnabled } from "./model-fallback-config"
|
||||
|
||||
type SafeHook = <THook>(hookName: HookName, factory: () => THook) => THook | null
|
||||
|
||||
type ModelFallbackSessionContext = {
|
||||
directory: string
|
||||
client: {
|
||||
session: {
|
||||
get: (input: { path: { id: string } }) => Promise<unknown>
|
||||
update: (input: {
|
||||
path: { id: string }
|
||||
body: { title: string }
|
||||
query: { directory: string }
|
||||
}) => Promise<unknown>
|
||||
}
|
||||
tui: {
|
||||
showToast: (input: {
|
||||
body: {
|
||||
title: string
|
||||
message: string
|
||||
variant: "success" | "error" | "info" | "warning"
|
||||
duration: number
|
||||
}
|
||||
}) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createFallbackTitleUpdater(
|
||||
ctx: ModelFallbackSessionContext,
|
||||
enabled: boolean,
|
||||
):
|
||||
| ((input: {
|
||||
sessionID: string
|
||||
providerID: string
|
||||
modelID: string
|
||||
variant?: string
|
||||
}) => Promise<void>)
|
||||
| undefined {
|
||||
if (!enabled) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const fallbackTitleMaxEntries = 200
|
||||
const fallbackTitleState = new Map<string, { baseTitle?: string; lastKey?: string }>()
|
||||
|
||||
return async (input) => {
|
||||
const key = `${input.providerID}/${input.modelID}${input.variant ? `:${input.variant}` : ""}`
|
||||
const existing = fallbackTitleState.get(input.sessionID) ?? {}
|
||||
if (existing.lastKey === key) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!existing.baseTitle) {
|
||||
const sessionResp = await ctx.client.session.get({ path: { id: input.sessionID } }).catch(() => null)
|
||||
const sessionInfo = sessionResp
|
||||
? normalizeSDKResponse(sessionResp, null as { title?: string } | null, {
|
||||
preferResponseOnMissingData: true,
|
||||
})
|
||||
: null
|
||||
const rawTitle = sessionInfo?.title
|
||||
if (typeof rawTitle === "string" && rawTitle.length > 0) {
|
||||
existing.baseTitle = rawTitle.replace(/\s*\[fallback:[^\]]+\]$/i, "").trim()
|
||||
} else {
|
||||
existing.baseTitle = "Session"
|
||||
}
|
||||
}
|
||||
|
||||
const variantLabel = input.variant ? ` ${input.variant}` : ""
|
||||
const newTitle = `${existing.baseTitle} [fallback: ${input.providerID}/${input.modelID}${variantLabel}]`
|
||||
|
||||
await ctx.client.session
|
||||
.update({
|
||||
path: { id: input.sessionID },
|
||||
body: { title: newTitle },
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
existing.lastKey = key
|
||||
fallbackTitleState.set(input.sessionID, existing)
|
||||
if (fallbackTitleState.size > fallbackTitleMaxEntries) {
|
||||
const oldestKey = fallbackTitleState.keys().next().value
|
||||
if (oldestKey) {
|
||||
fallbackTitleState.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createConfiguredModelFallbackHook(args: {
|
||||
ctx: ModelFallbackSessionContext
|
||||
pluginConfig: OhMyOpenCodeConfig
|
||||
isHookEnabled: (hookName: HookName) => boolean
|
||||
safeHook: SafeHook
|
||||
}): ReturnType<typeof createModelFallbackHook> | null {
|
||||
const { ctx, pluginConfig, isHookEnabled, safeHook } = args
|
||||
const isModelFallbackEnabled = resolveModelFallbackEnabled(pluginConfig)
|
||||
|
||||
if (!isModelFallbackEnabled || !isHookEnabled("model-fallback")) {
|
||||
return null
|
||||
}
|
||||
|
||||
const onApplied = createFallbackTitleUpdater(
|
||||
ctx,
|
||||
pluginConfig.experimental?.model_fallback_title ?? false,
|
||||
)
|
||||
|
||||
return safeHook("model-fallback", () =>
|
||||
createModelFallbackHook({
|
||||
toast: async ({ title, message, variant, duration }) => {
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title,
|
||||
message,
|
||||
variant: variant ?? "warning",
|
||||
duration: duration ?? 5000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
onApplied,
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { OhMyOpenCodeConfig, HookName } from "../../config"
|
||||
import type { ModelCacheState } from "../../plugin-state"
|
||||
import type { PluginContext } from "../types"
|
||||
|
||||
import {
|
||||
createContextWindowMonitorHook,
|
||||
createSessionRecoveryHook,
|
||||
createSessionNotification,
|
||||
createThinkModeHook,
|
||||
createModelFallbackHook,
|
||||
createAnthropicContextWindowLimitRecoveryHook,
|
||||
createAutoUpdateCheckerHook,
|
||||
createAgentUsageReminderHook,
|
||||
@@ -28,10 +31,10 @@ import {
|
||||
detectExternalNotificationPlugin,
|
||||
getNotificationConflictWarning,
|
||||
log,
|
||||
normalizeSDKResponse,
|
||||
} from "../../shared"
|
||||
import { safeCreateHook } from "../../shared/safe-create-hook"
|
||||
import { sessionExists } from "../../tools"
|
||||
import { createConfiguredModelFallbackHook } from "./create-model-fallback-session-hook"
|
||||
|
||||
export type SessionHooks = {
|
||||
contextWindowMonitor: ReturnType<typeof createContextWindowMonitorHook> | null
|
||||
@@ -39,7 +42,7 @@ export type SessionHooks = {
|
||||
sessionRecovery: ReturnType<typeof createSessionRecoveryHook> | null
|
||||
sessionNotification: ReturnType<typeof createSessionNotification> | null
|
||||
thinkMode: ReturnType<typeof createThinkModeHook> | null
|
||||
modelFallback: ReturnType<typeof createConfiguredModelFallbackHook>
|
||||
modelFallback: ReturnType<typeof createModelFallbackHook> | null
|
||||
anthropicContextWindowLimitRecovery: ReturnType<typeof createAnthropicContextWindowLimitRecoveryHook> | null
|
||||
autoUpdateChecker: ReturnType<typeof createAutoUpdateCheckerHook> | null
|
||||
agentUsageReminder: ReturnType<typeof createAgentUsageReminderHook> | null
|
||||
@@ -60,7 +63,7 @@ export type SessionHooks = {
|
||||
}
|
||||
|
||||
export function createSessionHooks(args: {
|
||||
ctx: Parameters<typeof createContextWindowMonitorHook>[0]
|
||||
ctx: PluginContext
|
||||
pluginConfig: OhMyOpenCodeConfig
|
||||
modelCacheState: ModelCacheState
|
||||
isHookEnabled: (hookName: HookName) => boolean
|
||||
@@ -102,12 +105,73 @@ export function createSessionHooks(args: {
|
||||
? safeHook("think-mode", () => createThinkModeHook())
|
||||
: null
|
||||
|
||||
const modelFallback = createConfiguredModelFallbackHook({
|
||||
ctx,
|
||||
pluginConfig,
|
||||
isHookEnabled,
|
||||
safeHook,
|
||||
})
|
||||
const enableFallbackTitle = pluginConfig.experimental?.model_fallback_title ?? false
|
||||
const fallbackTitleMaxEntries = 200
|
||||
const fallbackTitleState = new Map<string, { baseTitle?: string; lastKey?: string }>()
|
||||
const updateFallbackTitle = async (input: {
|
||||
sessionID: string
|
||||
providerID: string
|
||||
modelID: string
|
||||
variant?: string
|
||||
}) => {
|
||||
if (!enableFallbackTitle) return
|
||||
const key = `${input.providerID}/${input.modelID}${input.variant ? `:${input.variant}` : ""}`
|
||||
const existing = fallbackTitleState.get(input.sessionID) ?? {}
|
||||
if (existing.lastKey === key) return
|
||||
|
||||
if (!existing.baseTitle) {
|
||||
const sessionResp = await ctx.client.session.get({ path: { id: input.sessionID } }).catch(() => null)
|
||||
const sessionInfo = sessionResp
|
||||
? normalizeSDKResponse(sessionResp, null as { title?: string } | null, { preferResponseOnMissingData: true })
|
||||
: null
|
||||
const rawTitle = sessionInfo?.title
|
||||
if (typeof rawTitle === "string" && rawTitle.length > 0) {
|
||||
existing.baseTitle = rawTitle.replace(/\s*\[fallback:[^\]]+\]$/i, "").trim()
|
||||
} else {
|
||||
existing.baseTitle = "Session"
|
||||
}
|
||||
}
|
||||
|
||||
const variantLabel = input.variant ? ` ${input.variant}` : ""
|
||||
const newTitle = `${existing.baseTitle} [fallback: ${input.providerID}/${input.modelID}${variantLabel}]`
|
||||
|
||||
await ctx.client.session
|
||||
.update({
|
||||
path: { id: input.sessionID },
|
||||
body: { title: newTitle },
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
existing.lastKey = key
|
||||
fallbackTitleState.set(input.sessionID, existing)
|
||||
if (fallbackTitleState.size > fallbackTitleMaxEntries) {
|
||||
const oldestKey = fallbackTitleState.keys().next().value
|
||||
if (oldestKey) fallbackTitleState.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Model fallback hook (configurable via model_fallback config + disabled_hooks)
|
||||
// This handles automatic model switching when model errors occur
|
||||
const isModelFallbackConfigEnabled = pluginConfig.model_fallback ?? false
|
||||
const modelFallback = isModelFallbackConfigEnabled && isHookEnabled("model-fallback")
|
||||
? safeHook("model-fallback", () =>
|
||||
createModelFallbackHook({
|
||||
toast: async ({ title, message, variant, duration }) => {
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title,
|
||||
message,
|
||||
variant: variant ?? "warning",
|
||||
duration: duration ?? 5000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
onApplied: enableFallbackTitle ? updateFallbackTitle : undefined,
|
||||
}))
|
||||
: null
|
||||
|
||||
const anthropicContextWindowLimitRecovery = isHookEnabled("anthropic-context-window-limit-recovery")
|
||||
? safeHook("anthropic-context-window-limit-recovery", () =>
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
declare const require: (name: string) => any
|
||||
const { describe, expect, test } = require("bun:test")
|
||||
|
||||
import type { OhMyOpenCodeConfig } from "../../config"
|
||||
|
||||
import {
|
||||
hasConfiguredModelFallbacks,
|
||||
resolveModelFallbackEnabled,
|
||||
} from "./model-fallback-config"
|
||||
|
||||
describe("model-fallback-config", () => {
|
||||
test("detects agent fallback_models configuration", () => {
|
||||
//#given
|
||||
const pluginConfig: OhMyOpenCodeConfig = {
|
||||
agents: {
|
||||
sisyphus: {
|
||||
fallback_models: ["openai/gpt-5.2", "anthropic/claude-opus-4-6"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = hasConfiguredModelFallbacks(pluginConfig)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("auto-enables model fallback when category fallback_models are configured", () => {
|
||||
//#given
|
||||
const pluginConfig: OhMyOpenCodeConfig = {
|
||||
categories: {
|
||||
quick: {
|
||||
fallback_models: ["openai/gpt-5.2"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = resolveModelFallbackEnabled(pluginConfig)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("keeps model fallback disabled when explicitly turned off", () => {
|
||||
//#given
|
||||
const pluginConfig: OhMyOpenCodeConfig = {
|
||||
model_fallback: false,
|
||||
agents: {
|
||||
sisyphus: {
|
||||
fallback_models: ["openai/gpt-5.2"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = resolveModelFallbackEnabled(pluginConfig)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { OhMyOpenCodeConfig } from "../../config"
|
||||
|
||||
import { log, normalizeFallbackModels } from "../../shared"
|
||||
|
||||
type FallbackModelsConfig = {
|
||||
fallback_models?: string | string[]
|
||||
}
|
||||
|
||||
function hasFallbackModels(config: FallbackModelsConfig | undefined): boolean {
|
||||
return (normalizeFallbackModels(config?.fallback_models)?.length ?? 0) > 0
|
||||
}
|
||||
|
||||
export function hasConfiguredModelFallbacks(pluginConfig: OhMyOpenCodeConfig): boolean {
|
||||
const agentConfigs = Object.values<FallbackModelsConfig | undefined>(pluginConfig.agents ?? {})
|
||||
if (agentConfigs.some(hasFallbackModels)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const categoryConfigs = Object.values<FallbackModelsConfig | undefined>(pluginConfig.categories ?? {})
|
||||
return categoryConfigs.some(hasFallbackModels)
|
||||
}
|
||||
|
||||
export function resolveModelFallbackEnabled(pluginConfig: OhMyOpenCodeConfig): boolean {
|
||||
const hasConfiguredFallbacks = hasConfiguredModelFallbacks(pluginConfig)
|
||||
|
||||
if (pluginConfig.model_fallback === false && hasConfiguredFallbacks) {
|
||||
log(
|
||||
"model_fallback is disabled while fallback_models are configured; set model_fallback=true to keep provider fallback retries enabled",
|
||||
)
|
||||
}
|
||||
|
||||
return pluginConfig.model_fallback ?? hasConfiguredFallbacks
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { tmpdir } from "node:os"
|
||||
import { dirname, join } from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
import { createHashlineEditTool } from "../tools/hashline-edit"
|
||||
import { normalizeToolArgSchemas } from "./normalize-tool-arg-schemas"
|
||||
|
||||
const tempDirectories: string[] = []
|
||||
@@ -19,6 +20,13 @@ function getNestedRecord(record: Record<string, unknown>, key: string): Record<s
|
||||
return isRecord(value) ? value : undefined
|
||||
}
|
||||
|
||||
function getRecordAtPath(record: Record<string, unknown>, path: string[]): Record<string, unknown> | undefined {
|
||||
return path.reduce<Record<string, unknown> | undefined>(
|
||||
(currentRecord, key) => (currentRecord ? getNestedRecord(currentRecord, key) : undefined),
|
||||
record,
|
||||
)
|
||||
}
|
||||
|
||||
async function loadSeparateHostZodModule(): Promise<typeof import("zod")> {
|
||||
const pluginPackageDirectory = dirname(Bun.resolveSync("@opencode-ai/plugin/package.json", import.meta.dir))
|
||||
const sourceZodDirectory = join(pluginPackageDirectory, "node_modules", "zod")
|
||||
@@ -94,4 +102,29 @@ describe("normalizeToolArgSchemas", () => {
|
||||
expect(afterQuery?.title).toBe("Query")
|
||||
expect(afterQuery?.examples).toEqual(["issue 2314"])
|
||||
})
|
||||
|
||||
it("collapses hashline lines union into a Vertex-compatible array schema", async () => {
|
||||
// given
|
||||
const hostZod = await loadSeparateHostZodModule()
|
||||
const toolDefinition = createHashlineEditTool()
|
||||
|
||||
// when
|
||||
const beforeSchema = serializeWithHostZod(hostZod, toolDefinition.args)
|
||||
const beforeLines = getRecordAtPath(beforeSchema, ["properties", "edits", "items", "properties", "lines"])
|
||||
|
||||
normalizeToolArgSchemas(toolDefinition)
|
||||
|
||||
const afterSchema = serializeWithHostZod(hostZod, toolDefinition.args)
|
||||
const afterLines = getRecordAtPath(afterSchema, ["properties", "edits", "items", "properties", "lines"])
|
||||
const afterItems = afterLines ? getNestedRecord(afterLines, "items") : undefined
|
||||
|
||||
// then
|
||||
expect(beforeLines?.type).toBeUndefined()
|
||||
expect(Array.isArray(beforeLines?.anyOf)).toBe(true)
|
||||
expect(afterLines?.type).toBe("array")
|
||||
expect(afterLines?.nullable).toBe(true)
|
||||
expect(afterLines?.anyOf).toBeUndefined()
|
||||
expect(afterItems?.type).toBe("string")
|
||||
expect(afterLines?.description).toBe("Replacement or inserted lines. null/[] deletes with replace")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,11 +9,106 @@ type SchemaWithJsonSchemaOverride = ToolArgSchema & {
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function stripRootJsonSchemaFields(jsonSchema: Record<string, unknown>): Record<string, unknown> {
|
||||
const { $schema: _schema, ...rest } = jsonSchema
|
||||
return rest
|
||||
}
|
||||
|
||||
function isNullSchema(jsonSchema: Record<string, unknown>): boolean {
|
||||
return jsonSchema.type === "null"
|
||||
}
|
||||
|
||||
function isStringSchema(jsonSchema: Record<string, unknown>): boolean {
|
||||
return jsonSchema.type === "string"
|
||||
}
|
||||
|
||||
function isStringArraySchema(jsonSchema: Record<string, unknown>): boolean {
|
||||
if (jsonSchema.type !== "array") {
|
||||
return false
|
||||
}
|
||||
|
||||
const items = jsonSchema.items
|
||||
return isRecord(items) && items.type === "string"
|
||||
}
|
||||
|
||||
function collapseNullableUnion(
|
||||
jsonSchema: Record<string, unknown>,
|
||||
variants: Record<string, unknown>[],
|
||||
): Record<string, unknown> | null {
|
||||
const nonNullVariants = variants.filter((variant) => !isNullSchema(variant))
|
||||
|
||||
if (nonNullVariants.length !== 1 || variants.length !== nonNullVariants.length + 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { anyOf: _anyOf, ...schemaWithoutAnyOf } = jsonSchema
|
||||
return {
|
||||
...nonNullVariants[0],
|
||||
...schemaWithoutAnyOf,
|
||||
nullable: true,
|
||||
}
|
||||
}
|
||||
|
||||
function collapseStringOrStringArrayUnion(
|
||||
jsonSchema: Record<string, unknown>,
|
||||
variants: Record<string, unknown>[],
|
||||
): Record<string, unknown> | null {
|
||||
const nonNullVariants = variants.filter((variant) => !isNullSchema(variant))
|
||||
const stringVariant = nonNullVariants.find(isStringSchema)
|
||||
const stringArrayVariant = nonNullVariants.find(isStringArraySchema)
|
||||
|
||||
if (!stringVariant || !stringArrayVariant || nonNullVariants.length !== 2) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { anyOf: _anyOf, ...schemaWithoutAnyOf } = jsonSchema
|
||||
|
||||
return {
|
||||
...stringArrayVariant,
|
||||
...schemaWithoutAnyOf,
|
||||
nullable: variants.length !== nonNullVariants.length,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAnyOfUnion(jsonSchema: Record<string, unknown>): Record<string, unknown> {
|
||||
const anyOf = jsonSchema.anyOf
|
||||
if (!Array.isArray(anyOf) || jsonSchema.type !== undefined) {
|
||||
return jsonSchema
|
||||
}
|
||||
|
||||
const variants = anyOf.filter(isRecord)
|
||||
if (variants.length !== anyOf.length) {
|
||||
return jsonSchema
|
||||
}
|
||||
|
||||
return collapseNullableUnion(jsonSchema, variants) ?? collapseStringOrStringArrayUnion(jsonSchema, variants) ?? jsonSchema
|
||||
}
|
||||
|
||||
function normalizeJsonSchemaValue(jsonSchema: unknown): unknown {
|
||||
if (Array.isArray(jsonSchema)) {
|
||||
return jsonSchema.map((item) => normalizeJsonSchemaValue(item))
|
||||
}
|
||||
|
||||
if (!isRecord(jsonSchema)) {
|
||||
return jsonSchema
|
||||
}
|
||||
|
||||
return normalizeJsonSchema(jsonSchema)
|
||||
}
|
||||
|
||||
function normalizeJsonSchema(jsonSchema: Record<string, unknown>): Record<string, unknown> {
|
||||
const normalized: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(jsonSchema)) {
|
||||
normalized[key] = normalizeJsonSchemaValue(value)
|
||||
}
|
||||
|
||||
return normalizeAnyOfUnion(normalized)
|
||||
}
|
||||
|
||||
function attachJsonSchemaOverride(schema: SchemaWithJsonSchemaOverride): void {
|
||||
if (schema._zod.toJSONSchema) {
|
||||
return
|
||||
@@ -24,7 +119,7 @@ function attachJsonSchemaOverride(schema: SchemaWithJsonSchemaOverride): void {
|
||||
delete schema._zod.toJSONSchema
|
||||
|
||||
try {
|
||||
return stripRootJsonSchemaFields(tool.schema.toJSONSchema(schema))
|
||||
return normalizeJsonSchema(stripRootJsonSchemaFields(tool.schema.toJSONSchema(schema)))
|
||||
} finally {
|
||||
schema._zod.toJSONSchema = originalOverride
|
||||
}
|
||||
|
||||
@@ -40,28 +40,6 @@ describe("model-error-classifier", () => {
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("treats FreeUsageLimitError names as retryable", () => {
|
||||
//#given
|
||||
const error = { name: "FreeUsageLimitError" }
|
||||
|
||||
//#when
|
||||
const result = shouldRetryError(error)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("treats free tier usage limit messages as retryable", () => {
|
||||
//#given
|
||||
const error = { message: "Free tier daily limit reached for this provider" }
|
||||
|
||||
//#when
|
||||
const result = shouldRetryError(error)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("selectFallbackProvider prefers first connected provider in preference order", () => {
|
||||
//#given
|
||||
readConnectedProvidersCacheMock.mockReturnValue(["anthropic", "nvidia"])
|
||||
|
||||
@@ -6,14 +6,13 @@ import { readConnectedProvidersCache } from "./connected-providers-cache"
|
||||
* These errors completely halt the action loop and should trigger fallback retry.
|
||||
*/
|
||||
const RETRYABLE_ERROR_NAMES = new Set([
|
||||
"providermodelnotfounderror",
|
||||
"ratelimiterror",
|
||||
"quotaexceedederror",
|
||||
"insufficientcreditserror",
|
||||
"modelunavailableerror",
|
||||
"providerconnectionerror",
|
||||
"authenticationerror",
|
||||
"freeusagelimiterror",
|
||||
"ProviderModelNotFoundError",
|
||||
"RateLimitError",
|
||||
"QuotaExceededError",
|
||||
"InsufficientCreditsError",
|
||||
"ModelUnavailableError",
|
||||
"ProviderConnectionError",
|
||||
"AuthenticationError",
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -21,28 +20,24 @@ const RETRYABLE_ERROR_NAMES = new Set([
|
||||
* These errors are typically user-induced or fixable without switching models.
|
||||
*/
|
||||
const NON_RETRYABLE_ERROR_NAMES = new Set([
|
||||
"messageabortederror",
|
||||
"permissiondeniederror",
|
||||
"contextlengtherror",
|
||||
"timeouterror",
|
||||
"validationerror",
|
||||
"syntaxerror",
|
||||
"usererror",
|
||||
"MessageAbortedError",
|
||||
"PermissionDeniedError",
|
||||
"ContextLengthError",
|
||||
"TimeoutError",
|
||||
"ValidationError",
|
||||
"SyntaxError",
|
||||
"UserError",
|
||||
])
|
||||
|
||||
/**
|
||||
* Message patterns that indicate a retryable error even without a known error name.
|
||||
*/
|
||||
const RETRYABLE_MESSAGE_PATTERNS: Array<string | RegExp> = [
|
||||
const RETRYABLE_MESSAGE_PATTERNS = [
|
||||
"rate_limit",
|
||||
"rate limit",
|
||||
"quota",
|
||||
"quota will reset after",
|
||||
"usage limit has been reached",
|
||||
/free\s+usage/i,
|
||||
/free\s+tier/i,
|
||||
/daily\s+limit/i,
|
||||
/limit\s+reached/i,
|
||||
"all credentials for model",
|
||||
"cooling down",
|
||||
"exhausted your capacity",
|
||||
@@ -82,11 +77,6 @@ function hasProviderAutoRetrySignal(message: string): boolean {
|
||||
return AUTO_RETRY_GATE_PATTERNS.some((pattern) => message.includes(pattern))
|
||||
}
|
||||
|
||||
function matchesRetryableMessagePattern(message: string): boolean {
|
||||
return RETRYABLE_MESSAGE_PATTERNS.some((pattern) =>
|
||||
typeof pattern === "string" ? message.includes(pattern) : pattern.test(message))
|
||||
}
|
||||
|
||||
export interface ErrorInfo {
|
||||
name?: string
|
||||
message?: string
|
||||
@@ -99,14 +89,12 @@ export interface ErrorInfo {
|
||||
export function isRetryableModelError(error: ErrorInfo): boolean {
|
||||
// If we have an error name, check against known lists
|
||||
if (error.name) {
|
||||
const normalizedErrorName = error.name.toLowerCase()
|
||||
|
||||
// Explicit non-retryable takes precedence
|
||||
if (NON_RETRYABLE_ERROR_NAMES.has(normalizedErrorName)) {
|
||||
if (NON_RETRYABLE_ERROR_NAMES.has(error.name)) {
|
||||
return false
|
||||
}
|
||||
// Check if it's a known retryable error
|
||||
if (RETRYABLE_ERROR_NAMES.has(normalizedErrorName)) {
|
||||
if (RETRYABLE_ERROR_NAMES.has(error.name)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -116,7 +104,7 @@ export function isRetryableModelError(error: ErrorInfo): boolean {
|
||||
if (hasProviderAutoRetrySignal(msg)) {
|
||||
return true
|
||||
}
|
||||
return matchesRetryableMessagePattern(msg)
|
||||
return RETRYABLE_MESSAGE_PATTERNS.some((pattern) => msg.includes(pattern))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -192,6 +192,25 @@ describe("createHashlineEditTool", () => {
|
||||
expect(result).toContain("non-empty")
|
||||
})
|
||||
|
||||
it("treats replace with null lines as deletion", async () => {
|
||||
//#given
|
||||
const filePath = path.join(tempDir, "delete-line.txt")
|
||||
fs.writeFileSync(filePath, "line1\nline2\nline3")
|
||||
const line2Hash = computeLineHash(2, "line2")
|
||||
|
||||
//#when
|
||||
await tool.execute(
|
||||
{
|
||||
filePath,
|
||||
edits: [{ op: "replace", pos: `2#${line2Hash}`, lines: null }],
|
||||
},
|
||||
createMockContext(),
|
||||
)
|
||||
|
||||
//#then
|
||||
expect(fs.readFileSync(filePath, "utf-8")).toBe("line1\nline3")
|
||||
})
|
||||
|
||||
it("supports file rename with edits", async () => {
|
||||
//#given
|
||||
const filePath = path.join(tempDir, "source.txt")
|
||||
|
||||
Reference in New Issue
Block a user