Merge branch 'dev' into fix/issue-2232

This commit is contained in:
YeonGyu-Kim
2026-03-11 20:20:04 +09:00
committed by GitHub
210 changed files with 7089 additions and 1274 deletions
+35 -15
View File
@@ -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 () => {
+3 -3
View File
@@ -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 })
}
+27 -2
View File
@@ -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)
})
})
+27 -5
View File
@@ -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" },
])
})
})
+75
View File
@@ -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
}
+37 -20
View File
@@ -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 })
})
+37 -28
View File
@@ -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")
})
})
+35 -1
View File
@@ -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"
+4 -3
View File
@@ -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()
+13
View File
@@ -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: "*" },
]
+19
View File
@@ -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 "?"
}