Merge branch 'dev' into fix/issue-2232
This commit is contained in:
@@ -1,27 +1,47 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test"
|
||||
import { existsSync, mkdirSync, rmSync } from "fs"
|
||||
import { join } from "path"
|
||||
import * as dataPath from "./data-path"
|
||||
import { updateConnectedProvidersCache, readProviderModelsCache } from "./connected-providers-cache"
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
const TEST_CACHE_DIR = join(import.meta.dir, "__test-cache__")
|
||||
import { beforeAll, beforeEach, afterEach, describe, expect, mock, test } from "bun:test"
|
||||
|
||||
import { existsSync, mkdtempSync, rmSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import * as dataPath from "./data-path"
|
||||
|
||||
let testCacheDir = ""
|
||||
let moduleImportCounter = 0
|
||||
|
||||
const getOmoOpenCodeCacheDirMock = mock(() => testCacheDir)
|
||||
|
||||
let updateConnectedProvidersCache: typeof import("./connected-providers-cache").updateConnectedProvidersCache
|
||||
let readProviderModelsCache: typeof import("./connected-providers-cache").readProviderModelsCache
|
||||
|
||||
describe("updateConnectedProvidersCache", () => {
|
||||
let cacheDirSpy: ReturnType<typeof spyOn>
|
||||
beforeAll(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
cacheDirSpy = spyOn(dataPath, "getOmoOpenCodeCacheDir").mockReturnValue(TEST_CACHE_DIR)
|
||||
if (existsSync(TEST_CACHE_DIR)) {
|
||||
rmSync(TEST_CACHE_DIR, { recursive: true })
|
||||
beforeEach(async () => {
|
||||
mock.restore()
|
||||
const realCacheDir = join(dataPath.getCacheDir(), "oh-my-opencode")
|
||||
if (existsSync(realCacheDir)) {
|
||||
rmSync(realCacheDir, { recursive: true, force: true })
|
||||
}
|
||||
mkdirSync(TEST_CACHE_DIR, { recursive: true })
|
||||
|
||||
testCacheDir = mkdtempSync(join(tmpdir(), "connected-providers-cache-test-"))
|
||||
getOmoOpenCodeCacheDirMock.mockClear()
|
||||
mock.module("./data-path", () => ({
|
||||
getOmoOpenCodeCacheDir: getOmoOpenCodeCacheDirMock,
|
||||
}))
|
||||
moduleImportCounter += 1
|
||||
;({ updateConnectedProvidersCache, readProviderModelsCache } = await import(`./connected-providers-cache?test=${moduleImportCounter}`))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cacheDirSpy.mockRestore()
|
||||
if (existsSync(TEST_CACHE_DIR)) {
|
||||
rmSync(TEST_CACHE_DIR, { recursive: true })
|
||||
mock.restore()
|
||||
if (existsSync(testCacheDir)) {
|
||||
rmSync(testCacheDir, { recursive: true, force: true })
|
||||
}
|
||||
testCacheDir = ""
|
||||
})
|
||||
|
||||
test("extracts models from provider.list().all response", async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { log } from "./logger"
|
||||
import { getOmoOpenCodeCacheDir } from "./data-path"
|
||||
import * as dataPath from "./data-path"
|
||||
|
||||
const CONNECTED_PROVIDERS_CACHE_FILE = "connected-providers.json"
|
||||
const PROVIDER_MODELS_CACHE_FILE = "provider-models.json"
|
||||
@@ -26,11 +26,11 @@ interface ProviderModelsCache {
|
||||
}
|
||||
|
||||
function getCacheFilePath(filename: string): string {
|
||||
return join(getOmoOpenCodeCacheDir(), filename)
|
||||
return join(dataPath.getOmoOpenCodeCacheDir(), filename)
|
||||
}
|
||||
|
||||
function ensureCacheDir(): void {
|
||||
const cacheDir = getOmoOpenCodeCacheDir()
|
||||
const cacheDir = dataPath.getOmoOpenCodeCacheDir()
|
||||
if (!existsSync(cacheDir)) {
|
||||
mkdirSync(cacheDir, { recursive: true })
|
||||
}
|
||||
|
||||
@@ -24,7 +24,10 @@ function resetContextLimitEnv(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function createContextUsageMockContext(inputTokens: number) {
|
||||
function createContextUsageMockContext(
|
||||
inputTokens: number,
|
||||
options?: { providerID?: string; modelID?: string; cacheRead?: number }
|
||||
) {
|
||||
return {
|
||||
client: {
|
||||
session: {
|
||||
@@ -33,11 +36,13 @@ function createContextUsageMockContext(inputTokens: number) {
|
||||
{
|
||||
info: {
|
||||
role: "assistant",
|
||||
providerID: options?.providerID ?? "anthropic",
|
||||
modelID: options?.modelID,
|
||||
tokens: {
|
||||
input: inputTokens,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
cache: { read: options?.cacheRead ?? 0, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -99,4 +104,24 @@ describe("getContextWindowUsage", () => {
|
||||
expect(usage?.usagePercentage).toBe(0.3)
|
||||
expect(usage?.remainingTokens).toBe(700000)
|
||||
})
|
||||
|
||||
it("uses model-specific limit for non-anthropic providers when cached", async () => {
|
||||
// given
|
||||
const modelContextLimitsCache = new Map<string, number>()
|
||||
modelContextLimitsCache.set("opencode/kimi-k2.5-free", 262144)
|
||||
const ctx = createContextUsageMockContext(180000, {
|
||||
providerID: "opencode",
|
||||
modelID: "kimi-k2.5-free",
|
||||
})
|
||||
|
||||
// when
|
||||
const usage = await getContextWindowUsage(ctx as never, "ses_model_limit", {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(usage?.usagePercentage).toBeCloseTo(180000 / 262144)
|
||||
expect(usage?.remainingTokens).toBe(82144)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ const DEFAULT_TARGET_MAX_TOKENS = 50_000;
|
||||
|
||||
type ModelCacheStateLike = {
|
||||
anthropicContext1MEnabled: boolean;
|
||||
modelContextLimitsCache?: Map<string, number>;
|
||||
}
|
||||
|
||||
function getAnthropicActualLimit(modelCacheState?: ModelCacheStateLike): number {
|
||||
@@ -17,8 +18,14 @@ function getAnthropicActualLimit(modelCacheState?: ModelCacheStateLike): number
|
||||
: DEFAULT_ANTHROPIC_ACTUAL_LIMIT;
|
||||
}
|
||||
|
||||
function isAnthropicProvider(providerID: string): boolean {
|
||||
return providerID === "anthropic" || providerID === "google-vertex-anthropic";
|
||||
}
|
||||
|
||||
interface AssistantMessageInfo {
|
||||
role: "assistant";
|
||||
providerID?: string;
|
||||
modelID?: string;
|
||||
tokens: {
|
||||
input: number;
|
||||
output: number;
|
||||
@@ -136,20 +143,35 @@ export async function getContextWindowUsage(
|
||||
.map((m) => m.info as AssistantMessageInfo);
|
||||
|
||||
if (assistantMessages.length === 0) return null;
|
||||
|
||||
|
||||
const lastAssistant = assistantMessages[assistantMessages.length - 1];
|
||||
const lastTokens = lastAssistant.tokens;
|
||||
const lastTokens = lastAssistant?.tokens;
|
||||
if (!lastAssistant || !lastTokens) return null;
|
||||
|
||||
const cachedLimit =
|
||||
lastAssistant.providerID !== undefined && lastAssistant.modelID !== undefined
|
||||
? modelCacheState?.modelContextLimitsCache?.get(
|
||||
`${lastAssistant.providerID}/${lastAssistant.modelID}`,
|
||||
)
|
||||
: undefined;
|
||||
const actualLimit =
|
||||
cachedLimit ??
|
||||
(lastAssistant.providerID !== undefined && isAnthropicProvider(lastAssistant.providerID)
|
||||
? getAnthropicActualLimit(modelCacheState)
|
||||
: null);
|
||||
|
||||
if (!actualLimit) return null;
|
||||
|
||||
const usedTokens =
|
||||
(lastTokens?.input ?? 0) +
|
||||
(lastTokens?.cache?.read ?? 0) +
|
||||
(lastTokens?.output ?? 0);
|
||||
const anthropicActualLimit = getAnthropicActualLimit(modelCacheState);
|
||||
const remainingTokens = anthropicActualLimit - usedTokens;
|
||||
const remainingTokens = actualLimit - usedTokens;
|
||||
|
||||
return {
|
||||
usedTokens,
|
||||
remainingTokens,
|
||||
usagePercentage: usedTokens / anthropicActualLimit,
|
||||
usagePercentage: usedTokens / actualLimit,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { buildFallbackChainFromModels, parseFallbackModelEntry } from "./fallback-chain-from-models"
|
||||
|
||||
describe("fallback-chain-from-models", () => {
|
||||
test("parses provider/model entry with parenthesized variant", () => {
|
||||
//#given
|
||||
const fallbackModel = "openai/gpt-5.2(high)"
|
||||
|
||||
//#when
|
||||
const parsed = parseFallbackModelEntry(fallbackModel, "quotio")
|
||||
|
||||
//#then
|
||||
expect(parsed).toEqual({
|
||||
providers: ["openai"],
|
||||
model: "gpt-5.2",
|
||||
variant: "high",
|
||||
})
|
||||
})
|
||||
|
||||
test("uses default provider when fallback model omits provider prefix", () => {
|
||||
//#given
|
||||
const fallbackModel = "glm-5"
|
||||
|
||||
//#when
|
||||
const parsed = parseFallbackModelEntry(fallbackModel, "quotio")
|
||||
|
||||
//#then
|
||||
expect(parsed).toEqual({
|
||||
providers: ["quotio"],
|
||||
model: "glm-5",
|
||||
variant: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
test("builds fallback chain from normalized fallback_models input", () => {
|
||||
//#given
|
||||
const fallbackModels = ["quotio/kimi-k2.5", "gpt-5.2 medium"]
|
||||
|
||||
//#when
|
||||
const chain = buildFallbackChainFromModels(fallbackModels, "quotio")
|
||||
|
||||
//#then
|
||||
expect(chain).toEqual([
|
||||
{ providers: ["quotio"], model: "kimi-k2.5", variant: undefined },
|
||||
{ providers: ["quotio"], model: "gpt-5.2", variant: "medium" },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { FallbackEntry } from "./model-requirements"
|
||||
import { normalizeFallbackModels } from "./model-resolver"
|
||||
|
||||
const KNOWN_VARIANTS = new Set([
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
"none",
|
||||
"auto",
|
||||
"thinking",
|
||||
])
|
||||
|
||||
function parseVariantFromModel(rawModel: string): { modelID: string; variant?: string } {
|
||||
const trimmedModel = rawModel.trim()
|
||||
if (!trimmedModel) {
|
||||
return { modelID: "" }
|
||||
}
|
||||
|
||||
const parenthesizedVariant = trimmedModel.match(/^(.*)\(([^()]+)\)\s*$/)
|
||||
if (parenthesizedVariant) {
|
||||
const modelID = parenthesizedVariant[1]?.trim() ?? ""
|
||||
const variant = parenthesizedVariant[2]?.trim()
|
||||
return variant ? { modelID, variant } : { modelID }
|
||||
}
|
||||
|
||||
const spaceVariant = trimmedModel.match(/^(.*\S)\s+([a-z][a-z0-9_-]*)$/i)
|
||||
if (spaceVariant) {
|
||||
const modelID = spaceVariant[1]?.trim() ?? ""
|
||||
const variant = spaceVariant[2]?.trim().toLowerCase()
|
||||
if (variant && KNOWN_VARIANTS.has(variant)) {
|
||||
return { modelID, variant }
|
||||
}
|
||||
}
|
||||
|
||||
return { modelID: trimmedModel }
|
||||
}
|
||||
|
||||
export function parseFallbackModelEntry(
|
||||
model: string,
|
||||
defaultProviderID: string,
|
||||
): FallbackEntry | undefined {
|
||||
const trimmed = model.trim()
|
||||
if (!trimmed) return undefined
|
||||
|
||||
const parts = trimmed.split("/")
|
||||
const providerID = parts.length >= 2 ? parts[0].trim() : defaultProviderID
|
||||
const rawModelID = parts.length >= 2 ? parts.slice(1).join("/").trim() : trimmed
|
||||
if (!providerID || !rawModelID) return undefined
|
||||
|
||||
const parsed = parseVariantFromModel(rawModelID)
|
||||
if (!parsed.modelID) return undefined
|
||||
|
||||
return {
|
||||
providers: [providerID],
|
||||
model: parsed.modelID,
|
||||
variant: parsed.variant,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildFallbackChainFromModels(
|
||||
fallbackModels: string | string[] | undefined,
|
||||
defaultProviderID: string,
|
||||
): FallbackEntry[] | undefined {
|
||||
const normalized = normalizeFallbackModels(fallbackModels)
|
||||
if (!normalized || normalized.length === 0) return undefined
|
||||
|
||||
const parsed = normalized
|
||||
.map((model) => parseFallbackModelEntry(model, defaultProviderID))
|
||||
.filter((entry): entry is FallbackEntry => entry !== undefined)
|
||||
|
||||
if (parsed.length === 0) return undefined
|
||||
return parsed
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
declare const require: (name: string) => any
|
||||
const { describe, it, expect, beforeEach, afterEach, beforeAll } = require("bun:test")
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "fs"
|
||||
const { describe, it, expect, beforeEach, afterEach, beforeAll, spyOn } = require("bun:test")
|
||||
import { mkdtempSync, writeFileSync, rmSync, existsSync, readFileSync } from "fs"
|
||||
import { tmpdir } from "os"
|
||||
import { join } from "path"
|
||||
import * as connectedProvidersCache from "./connected-providers-cache"
|
||||
|
||||
let __resetModelCache: () => void
|
||||
let fetchAvailableModels: (client?: unknown, options?: { connectedProviders?: string[] | null }) => Promise<Set<string>>
|
||||
@@ -33,25 +34,27 @@ beforeAll(async () => {
|
||||
})
|
||||
|
||||
describe("fetchAvailableModels", () => {
|
||||
let tempDir: string
|
||||
let tempDir: string
|
||||
let originalXdgCache: string | undefined
|
||||
let providerModelsCacheSpy: { mockRestore(): void } | undefined
|
||||
|
||||
|
||||
beforeEach(() => {
|
||||
__resetModelCache()
|
||||
tempDir = mkdtempSync(join(tmpdir(), "opencode-test-"))
|
||||
beforeEach(() => {
|
||||
__resetModelCache()
|
||||
tempDir = mkdtempSync(join(tmpdir(), "opencode-test-"))
|
||||
originalXdgCache = process.env.XDG_CACHE_HOME
|
||||
process.env.XDG_CACHE_HOME = tempDir
|
||||
})
|
||||
providerModelsCacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalXdgCache !== undefined) {
|
||||
afterEach(() => {
|
||||
providerModelsCacheSpy?.mockRestore()
|
||||
if (originalXdgCache !== undefined) {
|
||||
process.env.XDG_CACHE_HOME = originalXdgCache
|
||||
} else {
|
||||
delete process.env.XDG_CACHE_HOME
|
||||
}
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function writeModelsCache(data: Record<string, any>) {
|
||||
const cacheDir = join(tempDir, "opencode")
|
||||
@@ -485,15 +488,18 @@ describe("getConnectedProviders", () => {
|
||||
describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
let tempDir: string
|
||||
let originalXdgCache: string | undefined
|
||||
let providerModelsCacheSpy: { mockRestore(): void } | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
__resetModelCache()
|
||||
tempDir = mkdtempSync(join(tmpdir(), "opencode-test-"))
|
||||
originalXdgCache = process.env.XDG_CACHE_HOME
|
||||
process.env.XDG_CACHE_HOME = tempDir
|
||||
providerModelsCacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
providerModelsCacheSpy?.mockRestore()
|
||||
if (originalXdgCache !== undefined) {
|
||||
process.env.XDG_CACHE_HOME = originalXdgCache
|
||||
} else {
|
||||
@@ -652,15 +658,24 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
describe("fetchAvailableModels with provider-models cache (whitelist-filtered)", () => {
|
||||
let tempDir: string
|
||||
let originalXdgCache: string | undefined
|
||||
let providerModelsCacheSpy: { mockRestore(): void } | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
__resetModelCache()
|
||||
tempDir = mkdtempSync(join(tmpdir(), "opencode-test-"))
|
||||
originalXdgCache = process.env.XDG_CACHE_HOME
|
||||
process.env.XDG_CACHE_HOME = tempDir
|
||||
providerModelsCacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockImplementation(() => {
|
||||
const cacheFile = join(tempDir, "oh-my-opencode", "provider-models.json")
|
||||
if (!existsSync(cacheFile)) {
|
||||
return null
|
||||
}
|
||||
return JSON.parse(readFileSync(cacheFile, "utf-8"))
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
providerModelsCacheSpy?.mockRestore()
|
||||
if (originalXdgCache !== undefined) {
|
||||
process.env.XDG_CACHE_HOME = originalXdgCache
|
||||
} else {
|
||||
@@ -878,21 +893,23 @@ describe("isModelAvailable", () => {
|
||||
|
||||
describe("fallback model availability", () => {
|
||||
let tempDir: string
|
||||
let originalXdgCache: string | undefined
|
||||
let connectedProvidersCacheSpy: { mockRestore(): void } | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
// given
|
||||
tempDir = mkdtempSync(join(tmpdir(), "opencode-test-"))
|
||||
originalXdgCache = process.env.XDG_CACHE_HOME
|
||||
process.env.XDG_CACHE_HOME = tempDir
|
||||
connectedProvidersCacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockImplementation(() => {
|
||||
const cacheFile = join(tempDir, "oh-my-opencode", "connected-providers.json")
|
||||
if (!existsSync(cacheFile)) {
|
||||
return null
|
||||
}
|
||||
const cache = JSON.parse(readFileSync(cacheFile, "utf-8")) as { connected?: string[] }
|
||||
return Array.isArray(cache.connected) ? cache.connected : null
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalXdgCache !== undefined) {
|
||||
process.env.XDG_CACHE_HOME = originalXdgCache
|
||||
} else {
|
||||
delete process.env.XDG_CACHE_HOME
|
||||
}
|
||||
connectedProvidersCacheSpy?.mockRestore()
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
|
||||
@@ -1,28 +1,18 @@
|
||||
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
|
||||
declare const require: (name: string) => any
|
||||
const { describe, expect, test, beforeEach, mock } = require("bun:test")
|
||||
|
||||
const readConnectedProvidersCacheMock = mock(() => null)
|
||||
|
||||
mock.module("./connected-providers-cache", () => ({
|
||||
readConnectedProvidersCache: readConnectedProvidersCacheMock,
|
||||
}))
|
||||
|
||||
import { mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import * as dataPath from "./data-path"
|
||||
import { shouldRetryError, selectFallbackProvider } from "./model-error-classifier"
|
||||
|
||||
const TEST_CACHE_DIR = join(import.meta.dir, "__test-cache__")
|
||||
|
||||
describe("model-error-classifier", () => {
|
||||
let cacheDirSpy: ReturnType<typeof spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
cacheDirSpy = spyOn(dataPath, "getOmoOpenCodeCacheDir").mockReturnValue(TEST_CACHE_DIR)
|
||||
if (existsSync(TEST_CACHE_DIR)) {
|
||||
rmSync(TEST_CACHE_DIR, { recursive: true })
|
||||
}
|
||||
mkdirSync(TEST_CACHE_DIR, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cacheDirSpy.mockRestore()
|
||||
if (existsSync(TEST_CACHE_DIR)) {
|
||||
rmSync(TEST_CACHE_DIR, { recursive: true })
|
||||
}
|
||||
readConnectedProvidersCacheMock.mockReturnValue(null)
|
||||
readConnectedProvidersCacheMock.mockClear()
|
||||
})
|
||||
|
||||
test("treats overloaded retry messages as retryable", () => {
|
||||
@@ -36,12 +26,23 @@ describe("model-error-classifier", () => {
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("treats cooling-down auto-retry messages as retryable", () => {
|
||||
//#given
|
||||
const error = {
|
||||
message:
|
||||
"All credentials for model claude-opus-4-6-thinking are cooling down [retrying in ~5 days attempt #1]",
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = shouldRetryError(error)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("selectFallbackProvider prefers first connected provider in preference order", () => {
|
||||
//#given
|
||||
writeFileSync(
|
||||
join(TEST_CACHE_DIR, "connected-providers.json"),
|
||||
JSON.stringify({ connected: ["anthropic", "nvidia"], updatedAt: new Date().toISOString() }, null, 2),
|
||||
)
|
||||
readConnectedProvidersCacheMock.mockReturnValue(["anthropic", "nvidia"])
|
||||
|
||||
//#when
|
||||
const provider = selectFallbackProvider(["anthropic", "nvidia"], "nvidia")
|
||||
@@ -52,10 +53,7 @@ describe("model-error-classifier", () => {
|
||||
|
||||
test("selectFallbackProvider falls back to next connected provider when first is disconnected", () => {
|
||||
//#given
|
||||
writeFileSync(
|
||||
join(TEST_CACHE_DIR, "connected-providers.json"),
|
||||
JSON.stringify({ connected: ["nvidia"], updatedAt: new Date().toISOString() }, null, 2),
|
||||
)
|
||||
readConnectedProvidersCacheMock.mockReturnValue(["nvidia"])
|
||||
|
||||
//#when
|
||||
const provider = selectFallbackProvider(["anthropic", "nvidia"])
|
||||
@@ -73,4 +71,15 @@ describe("model-error-classifier", () => {
|
||||
//#then
|
||||
expect(provider).toBe("anthropic")
|
||||
})
|
||||
|
||||
test("selectFallbackProvider uses connected preferred provider when fallback providers are unavailable", () => {
|
||||
//#given
|
||||
readConnectedProvidersCacheMock.mockReturnValue(["provider-x"])
|
||||
|
||||
//#when
|
||||
const provider = selectFallbackProvider(["provider-y"], "provider-x")
|
||||
|
||||
//#then
|
||||
expect(provider).toBe("provider-x")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,6 +36,11 @@ const RETRYABLE_MESSAGE_PATTERNS = [
|
||||
"rate_limit",
|
||||
"rate limit",
|
||||
"quota",
|
||||
"quota will reset after",
|
||||
"usage limit has been reached",
|
||||
"all credentials for model",
|
||||
"cooling down",
|
||||
"exhausted your capacity",
|
||||
"not found",
|
||||
"unavailable",
|
||||
"insufficient",
|
||||
@@ -55,6 +60,23 @@ const RETRYABLE_MESSAGE_PATTERNS = [
|
||||
"504",
|
||||
]
|
||||
|
||||
const AUTO_RETRY_GATE_PATTERNS = [
|
||||
"rate limit",
|
||||
"quota",
|
||||
"usage limit",
|
||||
"limit reached",
|
||||
"cooling down",
|
||||
"credentials for model",
|
||||
"exhausted your capacity",
|
||||
]
|
||||
|
||||
function hasProviderAutoRetrySignal(message: string): boolean {
|
||||
if (!message.includes("retrying in")) {
|
||||
return false
|
||||
}
|
||||
return AUTO_RETRY_GATE_PATTERNS.some((pattern) => message.includes(pattern))
|
||||
}
|
||||
|
||||
export interface ErrorInfo {
|
||||
name?: string
|
||||
message?: string
|
||||
@@ -79,6 +101,9 @@ export function isRetryableModelError(error: ErrorInfo): boolean {
|
||||
|
||||
// Check message patterns for unknown errors
|
||||
const msg = error.message?.toLowerCase() ?? ""
|
||||
if (hasProviderAutoRetrySignal(msg)) {
|
||||
return true
|
||||
}
|
||||
return RETRYABLE_MESSAGE_PATTERNS.some((pattern) => msg.includes(pattern))
|
||||
}
|
||||
|
||||
@@ -115,7 +140,8 @@ export function hasMoreFallbacks(
|
||||
* Selects the best provider for a fallback entry.
|
||||
* Priority:
|
||||
* 1) First connected provider in the entry's provider preference order
|
||||
* 2) First provider listed in the fallback entry (when cache is missing)
|
||||
* 2) Preferred provider when connected (and entry providers are unavailable)
|
||||
* 3) First provider listed in the fallback entry
|
||||
*/
|
||||
export function selectFallbackProvider(
|
||||
providers: string[],
|
||||
@@ -124,11 +150,19 @@ export function selectFallbackProvider(
|
||||
const connectedProviders = readConnectedProvidersCache()
|
||||
if (connectedProviders) {
|
||||
const connectedSet = new Set(connectedProviders.map(p => p.toLowerCase()))
|
||||
|
||||
for (const provider of providers) {
|
||||
if (connectedSet.has(provider.toLowerCase())) {
|
||||
return provider
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
preferredProviderID &&
|
||||
connectedSet.has(preferredProviderID.toLowerCase())
|
||||
) {
|
||||
return preferredProviderID
|
||||
}
|
||||
}
|
||||
|
||||
return providers[0] || preferredProviderID || "opencode"
|
||||
|
||||
@@ -201,8 +201,8 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
expect(hephaestus.requiresModel).toBeUndefined()
|
||||
})
|
||||
|
||||
test("all 10 builtin agents have valid fallbackChain arrays", () => {
|
||||
// #given - list of 10 agent names
|
||||
test("all 11 builtin agents have valid fallbackChain arrays", () => {
|
||||
// #given - list of 11 agent names
|
||||
const expectedAgents = [
|
||||
"sisyphus",
|
||||
"hephaestus",
|
||||
@@ -214,13 +214,14 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
"metis",
|
||||
"momus",
|
||||
"atlas",
|
||||
"sisyphus-junior",
|
||||
]
|
||||
|
||||
// when - checking AGENT_MODEL_REQUIREMENTS
|
||||
const definedAgents = Object.keys(AGENT_MODEL_REQUIREMENTS)
|
||||
|
||||
// #then - all agents present with valid fallbackChain
|
||||
expect(definedAgents).toHaveLength(10)
|
||||
expect(definedAgents).toHaveLength(11)
|
||||
for (const agent of expectedAgents) {
|
||||
const requirement = AGENT_MODEL_REQUIREMENTS[agent]
|
||||
expect(requirement).toBeDefined()
|
||||
|
||||
@@ -170,6 +170,19 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.4", variant: "medium" },
|
||||
],
|
||||
},
|
||||
"sisyphus-junior": {
|
||||
fallbackChain: [
|
||||
{
|
||||
providers: ["anthropic", "github-copilot", "opencode"],
|
||||
model: "claude-sonnet-4-6",
|
||||
},
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.4", variant: "medium" },
|
||||
{
|
||||
providers: ["google", "github-copilot", "opencode"],
|
||||
model: "gemini-3-flash",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export type SessionPermissionRule = {
|
||||
permission: string
|
||||
action: "allow" | "deny"
|
||||
pattern: string
|
||||
}
|
||||
|
||||
export const QUESTION_DENIED_SESSION_PERMISSION: SessionPermissionRule[] = [
|
||||
{ permission: "question", action: "deny", pattern: "*" },
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
export function normalizeRetryStatusMessage(message: string): string {
|
||||
return message
|
||||
.replace(/\[retrying in [^\]]*attempt\s*#\d+\]/gi, "[retrying]")
|
||||
.replace(/retrying in\s+[^(]*attempt\s*#\d+/gi, "retrying")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
export function extractRetryAttempt(statusAttempt: unknown, message: string): string {
|
||||
if (typeof statusAttempt === "number" && Number.isFinite(statusAttempt)) {
|
||||
return String(statusAttempt)
|
||||
}
|
||||
const attemptMatch = message.match(/attempt\s*#\s*(\d+)/i)
|
||||
if (attemptMatch?.[1]) {
|
||||
return attemptMatch[1]
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
Reference in New Issue
Block a user