mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
Implements the model metadata caching refactor: persists resolvedModelInfo, removes TTL-based auto-expiration, adds explicit Refresh models flow, activation-time self-healing, and gated reinit on provider/model/baseUrl changes.
This commit is contained in:
parent
65230f1f5c
commit
49521cc499
41 changed files with 1780 additions and 326 deletions
|
|
@ -179,6 +179,8 @@ const baseProviderSettingsSchema = z.object({
|
|||
reasoningEffort: reasoningEffortWithMinimalSchema.optional(),
|
||||
modelMaxTokens: z.number().optional(),
|
||||
modelMaxThinkingTokens: z.number().optional(),
|
||||
// Persisted resolved model metadata (Phase 1 Step 1)
|
||||
resolvedModelInfo: modelInfoSchema.optional(),
|
||||
|
||||
// Model verbosity.
|
||||
verbosity: verbosityLevelsSchema.optional(),
|
||||
|
|
|
|||
|
|
@ -1,258 +1,254 @@
|
|||
// npx vitest run __tests__/extension.spec.ts
|
||||
|
||||
import type * as vscode from "vscode"
|
||||
import type { AuthState } from "@roo-code/types"
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
|
||||
vi.mock("vscode", () => ({
|
||||
window: {
|
||||
createOutputChannel: vi.fn().mockReturnValue({
|
||||
appendLine: vi.fn(),
|
||||
}),
|
||||
registerWebviewViewProvider: vi.fn(),
|
||||
registerUriHandler: vi.fn(),
|
||||
tabGroups: {
|
||||
onDidChangeTabs: vi.fn(),
|
||||
},
|
||||
onDidChangeActiveTextEditor: vi.fn(),
|
||||
createTextEditorDecorationType: vi.fn().mockReturnValue({ dispose: vi.fn() }),
|
||||
showErrorMessage: vi.fn(),
|
||||
showInformationMessage: vi.fn(),
|
||||
},
|
||||
CodeActionKind: {
|
||||
QuickFix: { value: "quickfix" },
|
||||
RefactorRewrite: { value: "refactor.rewrite" },
|
||||
},
|
||||
RelativePattern: vi.fn(),
|
||||
workspace: {
|
||||
registerTextDocumentContentProvider: vi.fn(),
|
||||
getConfiguration: vi.fn().mockReturnValue({
|
||||
get: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
createFileSystemWatcher: vi.fn().mockReturnValue({
|
||||
onDidCreate: vi.fn(),
|
||||
onDidChange: vi.fn(),
|
||||
onDidCreate: vi.fn(),
|
||||
onDidDelete: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
}),
|
||||
onDidChangeWorkspaceFolders: vi.fn(),
|
||||
},
|
||||
languages: {
|
||||
registerCodeActionsProvider: vi.fn(),
|
||||
},
|
||||
commands: {
|
||||
executeCommand: vi.fn(),
|
||||
},
|
||||
env: {
|
||||
language: "en",
|
||||
},
|
||||
ExtensionMode: {
|
||||
Production: 1,
|
||||
getConfiguration: vi.fn().mockReturnValue({ update: vi.fn() }),
|
||||
},
|
||||
env: { language: "en" },
|
||||
}))
|
||||
|
||||
vi.mock("@dotenvx/dotenvx", () => ({
|
||||
config: vi.fn(),
|
||||
vi.mock("../api", () => ({
|
||||
buildApiHandler: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockBridgeOrchestratorDisconnect = vi.fn().mockResolvedValue(undefined)
|
||||
import { ensureResolvedModelInfo } from "../extension"
|
||||
import { buildApiHandler } from "../api"
|
||||
import { ClineProvider } from "../core/webview/ClineProvider"
|
||||
|
||||
vi.mock("@roo-code/cloud", () => ({
|
||||
CloudService: {
|
||||
createInstance: vi.fn(),
|
||||
hasInstance: vi.fn().mockReturnValue(true),
|
||||
get instance() {
|
||||
return {
|
||||
off: vi.fn(),
|
||||
on: vi.fn(),
|
||||
getUserInfo: vi.fn().mockReturnValue(null),
|
||||
isTaskSyncEnabled: vi.fn().mockReturnValue(false),
|
||||
}
|
||||
},
|
||||
},
|
||||
BridgeOrchestrator: {
|
||||
disconnect: mockBridgeOrchestratorDisconnect,
|
||||
},
|
||||
getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"),
|
||||
}))
|
||||
|
||||
vi.mock("@roo-code/telemetry", () => ({
|
||||
TelemetryService: {
|
||||
createInstance: vi.fn().mockReturnValue({
|
||||
register: vi.fn(),
|
||||
setProvider: vi.fn(),
|
||||
shutdown: vi.fn(),
|
||||
}),
|
||||
get instance() {
|
||||
return {
|
||||
register: vi.fn(),
|
||||
setProvider: vi.fn(),
|
||||
shutdown: vi.fn(),
|
||||
}
|
||||
},
|
||||
},
|
||||
PostHogTelemetryClient: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("../utils/outputChannelLogger", () => ({
|
||||
createOutputChannelLogger: vi.fn().mockReturnValue(vi.fn()),
|
||||
createDualLogger: vi.fn().mockReturnValue(vi.fn()),
|
||||
}))
|
||||
|
||||
vi.mock("../shared/package", () => ({
|
||||
Package: {
|
||||
name: "test-extension",
|
||||
outputChannel: "Test Output",
|
||||
version: "1.0.0",
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../shared/language", () => ({
|
||||
formatLanguage: vi.fn().mockReturnValue("en"),
|
||||
}))
|
||||
|
||||
vi.mock("../core/config/ContextProxy", () => ({
|
||||
ContextProxy: {
|
||||
getInstance: vi.fn().mockResolvedValue({
|
||||
getValue: vi.fn(),
|
||||
setValue: vi.fn(),
|
||||
getValues: vi.fn().mockReturnValue({}),
|
||||
getProviderSettings: vi.fn().mockReturnValue({}),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../integrations/editor/DiffViewProvider", () => ({
|
||||
DIFF_VIEW_URI_SCHEME: "test-diff-scheme",
|
||||
}))
|
||||
|
||||
vi.mock("../integrations/terminal/TerminalRegistry", () => ({
|
||||
TerminalRegistry: {
|
||||
initialize: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../services/mcp/McpServerManager", () => ({
|
||||
McpServerManager: {
|
||||
cleanup: vi.fn().mockResolvedValue(undefined),
|
||||
getInstance: vi.fn().mockResolvedValue(null),
|
||||
unregisterProvider: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../services/code-index/manager", () => ({
|
||||
CodeIndexManager: {
|
||||
getInstance: vi.fn().mockReturnValue(null),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../services/mdm/MdmService", () => ({
|
||||
MdmService: {
|
||||
createInstance: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../utils/migrateSettings", () => ({
|
||||
migrateSettings: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock("../utils/autoImportSettings", () => ({
|
||||
autoImportSettings: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock("../extension/api", () => ({
|
||||
API: vi.fn().mockImplementation(() => ({})),
|
||||
}))
|
||||
|
||||
vi.mock("../activate", () => ({
|
||||
handleUri: vi.fn(),
|
||||
registerCommands: vi.fn(),
|
||||
registerCodeActions: vi.fn(),
|
||||
registerTerminalActions: vi.fn(),
|
||||
CodeActionProvider: vi.fn().mockImplementation(() => ({
|
||||
providedCodeActionKinds: [],
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock("../i18n", () => ({
|
||||
initializeI18n: vi.fn(),
|
||||
t: vi.fn((key) => key),
|
||||
}))
|
||||
|
||||
describe("extension.ts", () => {
|
||||
let mockContext: vscode.ExtensionContext
|
||||
let authStateChangedHandler:
|
||||
| ((data: { state: AuthState; previousState: AuthState }) => void | Promise<void>)
|
||||
| undefined
|
||||
describe("activation-time resolvedModelInfo", () => {
|
||||
let logSpy: ReturnType<typeof vi.spyOn>
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockBridgeOrchestratorDisconnect.mockClear()
|
||||
|
||||
mockContext = {
|
||||
extensionPath: "/test/path",
|
||||
globalState: {
|
||||
get: vi.fn().mockReturnValue(undefined),
|
||||
update: vi.fn(),
|
||||
},
|
||||
subscriptions: [],
|
||||
} as unknown as vscode.ExtensionContext
|
||||
|
||||
authStateChangedHandler = undefined
|
||||
logSpy = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||
})
|
||||
|
||||
test("authStateChangedHandler calls BridgeOrchestrator.disconnect when logged-out event fires", async () => {
|
||||
const { CloudService, BridgeOrchestrator } = await import("@roo-code/cloud")
|
||||
|
||||
// Capture the auth state changed handler.
|
||||
vi.mocked(CloudService.createInstance).mockImplementation(async (_context, _logger, handlers) => {
|
||||
if (handlers?.["auth-state-changed"]) {
|
||||
authStateChangedHandler = handlers["auth-state-changed"]
|
||||
}
|
||||
|
||||
return {
|
||||
off: vi.fn(),
|
||||
on: vi.fn(),
|
||||
telemetryClient: null,
|
||||
} as any
|
||||
})
|
||||
|
||||
// Activate the extension.
|
||||
const { activate } = await import("../extension")
|
||||
await activate(mockContext)
|
||||
|
||||
// Verify handler was registered.
|
||||
expect(authStateChangedHandler).toBeDefined()
|
||||
|
||||
// Trigger logout.
|
||||
await authStateChangedHandler!({
|
||||
state: "logged-out" as AuthState,
|
||||
previousState: "logged-in" as AuthState,
|
||||
})
|
||||
|
||||
// Verify BridgeOrchestrator.disconnect was called
|
||||
expect(mockBridgeOrchestratorDisconnect).toHaveBeenCalled()
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("authStateChangedHandler does not call BridgeOrchestrator.disconnect for other states", async () => {
|
||||
const { CloudService } = await import("@roo-code/cloud")
|
||||
it("populates missing resolvedModelInfo for a dynamic provider on activation", async () => {
|
||||
const provider: any = {
|
||||
getState: vi.fn().mockResolvedValue({
|
||||
apiConfiguration: { apiProvider: "openrouter", openRouterModelId: "openrouter/model" },
|
||||
currentApiConfigName: "default",
|
||||
}),
|
||||
upsertProviderProfile: vi.fn().mockResolvedValue("id"),
|
||||
}
|
||||
|
||||
// Capture the auth state changed handler.
|
||||
vi.mocked(CloudService.createInstance).mockImplementation(async (_context, _logger, handlers) => {
|
||||
if (handlers?.["auth-state-changed"]) {
|
||||
authStateChangedHandler = handlers["auth-state-changed"]
|
||||
}
|
||||
const info = { contextWindow: 4000, maxTokens: 8192, supportsPromptCache: true }
|
||||
const handler = {
|
||||
fetchModel: vi.fn().mockResolvedValue({ info }),
|
||||
getModel: vi.fn().mockReturnValue({ id: "openrouter/model", info }),
|
||||
}
|
||||
;(buildApiHandler as any).mockReturnValue(handler)
|
||||
|
||||
return {
|
||||
off: vi.fn(),
|
||||
on: vi.fn(),
|
||||
telemetryClient: null,
|
||||
} as any
|
||||
})
|
||||
await ensureResolvedModelInfo(provider)
|
||||
|
||||
// Activate the extension.
|
||||
const { activate } = await import("../extension")
|
||||
await activate(mockContext)
|
||||
expect(buildApiHandler).toHaveBeenCalled()
|
||||
expect(provider.upsertProviderProfile).toHaveBeenCalledWith(
|
||||
"default",
|
||||
expect.objectContaining({ resolvedModelInfo: info }),
|
||||
true,
|
||||
)
|
||||
expect(logSpy.mock.calls.some((c: any[]) => String(c.join(" ")).includes("Populating resolvedModelInfo"))).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
// Trigger login.
|
||||
await authStateChangedHandler!({
|
||||
state: "logged-in" as AuthState,
|
||||
previousState: "logged-out" as AuthState,
|
||||
})
|
||||
it("skips when resolvedModelInfo is valid", async () => {
|
||||
const resolved = { contextWindow: 16000, maxTokens: 8000 }
|
||||
const provider: any = {
|
||||
getState: vi.fn().mockResolvedValue({
|
||||
apiConfiguration: { apiProvider: "openrouter", resolvedModelInfo: resolved },
|
||||
currentApiConfigName: "default",
|
||||
}),
|
||||
upsertProviderProfile: vi.fn(),
|
||||
}
|
||||
|
||||
// Verify BridgeOrchestrator.disconnect was NOT called.
|
||||
expect(mockBridgeOrchestratorDisconnect).not.toHaveBeenCalled()
|
||||
await ensureResolvedModelInfo(provider)
|
||||
|
||||
expect(buildApiHandler).not.toHaveBeenCalled()
|
||||
expect(provider.upsertProviderProfile).not.toHaveBeenCalled()
|
||||
expect(
|
||||
logSpy.mock.calls.some((c: any[]) => String(c.join(" ")).includes("Using existing resolvedModelInfo")),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("skips for static providers", async () => {
|
||||
const provider: any = {
|
||||
getState: vi.fn().mockResolvedValue({
|
||||
apiConfiguration: { apiProvider: "anthropic", apiModelId: "claude-3-5-sonnet" },
|
||||
currentApiConfigName: "default",
|
||||
}),
|
||||
upsertProviderProfile: vi.fn(),
|
||||
}
|
||||
|
||||
await ensureResolvedModelInfo(provider)
|
||||
|
||||
expect(buildApiHandler).not.toHaveBeenCalled()
|
||||
expect(provider.upsertProviderProfile).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("settings save gating (Phase 3.2)", () => {
|
||||
let logSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
logSpy = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore()
|
||||
})
|
||||
|
||||
const bindProvider = (impl: any) => (ClineProvider.prototype.upsertProviderProfile as any).bind(impl)
|
||||
|
||||
it("does not reinit on unrelated setting change and preserves resolvedModelInfo", async () => {
|
||||
const prevConfig = {
|
||||
apiProvider: "openrouter",
|
||||
openRouterModelId: "openrouter/model",
|
||||
openRouterBaseUrl: "https://openrouter.ai/api/v1",
|
||||
resolvedModelInfo: { contextWindow: 4000, maxTokens: 8192 },
|
||||
modelTemperature: 0.1,
|
||||
}
|
||||
|
||||
const nextConfig = {
|
||||
...prevConfig,
|
||||
modelTemperature: 0.2, // unrelated change
|
||||
}
|
||||
|
||||
const provider: any = {
|
||||
providerSettingsManager: {
|
||||
saveConfig: vi.fn().mockResolvedValue("id"),
|
||||
listConfig: vi.fn().mockResolvedValue([]),
|
||||
setModeConfig: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
updateGlobalState: vi.fn().mockResolvedValue(undefined),
|
||||
contextProxy: { setProviderSettings: vi.fn().mockResolvedValue(undefined) },
|
||||
getState: vi.fn().mockResolvedValue({ apiConfiguration: prevConfig, mode: "architect" }),
|
||||
getCurrentTask: vi.fn().mockReturnValue({ api: undefined }),
|
||||
postStateToWebview: vi.fn().mockResolvedValue(undefined),
|
||||
log: vi.fn(),
|
||||
}
|
||||
|
||||
;(buildApiHandler as any).mockReturnValue({}) // handler if reinit (should NOT be called)
|
||||
|
||||
const upsert = bindProvider(provider)
|
||||
await upsert("default", nextConfig, true)
|
||||
|
||||
expect(provider.providerSettingsManager.saveConfig).toHaveBeenCalledWith("default", nextConfig)
|
||||
expect(provider.contextProxy.setProviderSettings).toHaveBeenCalledWith(nextConfig)
|
||||
expect(buildApiHandler).not.toHaveBeenCalled()
|
||||
expect(
|
||||
logSpy.mock.calls.some((c: any[]) =>
|
||||
String(c.join(" ")).includes("[model-cache/save] No reinit: provider/model/baseUrl unchanged"),
|
||||
),
|
||||
).toBe(true)
|
||||
// Ensure resolvedModelInfo remained intact in persisted payload
|
||||
expect((provider.providerSettingsManager.saveConfig as any).mock.calls[0][1].resolvedModelInfo).toEqual(
|
||||
prevConfig.resolvedModelInfo,
|
||||
)
|
||||
})
|
||||
|
||||
it("reinit when provider/model/baseUrl changes (modelId change)", async () => {
|
||||
const prevConfig = {
|
||||
apiProvider: "openrouter",
|
||||
openRouterModelId: "openrouter/model",
|
||||
openRouterBaseUrl: "https://openrouter.ai/api/v1",
|
||||
}
|
||||
|
||||
const nextConfig = {
|
||||
...prevConfig,
|
||||
openRouterModelId: "openrouter/other-model", // model change should trigger reinit
|
||||
}
|
||||
|
||||
const handler = {}
|
||||
;(buildApiHandler as any).mockReturnValue(handler)
|
||||
|
||||
const task: any = { api: undefined }
|
||||
|
||||
const provider: any = {
|
||||
providerSettingsManager: {
|
||||
saveConfig: vi.fn().mockResolvedValue("id"),
|
||||
listConfig: vi.fn().mockResolvedValue([]),
|
||||
setModeConfig: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
updateGlobalState: vi.fn().mockResolvedValue(undefined),
|
||||
contextProxy: { setProviderSettings: vi.fn().mockResolvedValue(undefined) },
|
||||
getState: vi.fn().mockResolvedValue({ apiConfiguration: prevConfig, mode: "architect" }),
|
||||
getCurrentTask: vi.fn().mockReturnValue(task),
|
||||
postStateToWebview: vi.fn().mockResolvedValue(undefined),
|
||||
log: vi.fn(),
|
||||
}
|
||||
|
||||
const upsert = bindProvider(provider)
|
||||
await upsert("default", nextConfig, true)
|
||||
|
||||
expect(provider.providerSettingsManager.saveConfig).toHaveBeenCalledWith("default", nextConfig)
|
||||
expect(buildApiHandler).toHaveBeenCalledWith(nextConfig)
|
||||
expect(task.api).toBe(handler)
|
||||
expect(
|
||||
logSpy.mock.calls.some((c: any[]) =>
|
||||
String(c.join(" ")).includes("[model-cache/save] Reinit: relevant fields changed"),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("reinit when router baseUrl changes", async () => {
|
||||
const prevConfig = {
|
||||
apiProvider: "requesty",
|
||||
requestyModelId: "requesty/model",
|
||||
requestyBaseUrl: "https://api.requesty.ai",
|
||||
}
|
||||
|
||||
const nextConfig = {
|
||||
...prevConfig,
|
||||
requestyBaseUrl: "https://custom.requesty.ai", // baseUrl change should trigger reinit
|
||||
}
|
||||
|
||||
const handler = {}
|
||||
;(buildApiHandler as any).mockReturnValue(handler)
|
||||
|
||||
const task: any = { api: undefined }
|
||||
|
||||
const provider: any = {
|
||||
providerSettingsManager: {
|
||||
saveConfig: vi.fn().mockResolvedValue("id"),
|
||||
listConfig: vi.fn().mockResolvedValue([]),
|
||||
setModeConfig: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
updateGlobalState: vi.fn().mockResolvedValue(undefined),
|
||||
contextProxy: { setProviderSettings: vi.fn().mockResolvedValue(undefined) },
|
||||
getState: vi.fn().mockResolvedValue({ apiConfiguration: prevConfig, mode: "architect" }),
|
||||
getCurrentTask: vi.fn().mockReturnValue(task),
|
||||
postStateToWebview: vi.fn().mockResolvedValue(undefined),
|
||||
log: vi.fn(),
|
||||
}
|
||||
|
||||
const upsert = bindProvider(provider)
|
||||
await upsert("default", nextConfig, true)
|
||||
|
||||
expect(buildApiHandler).toHaveBeenCalledWith(nextConfig)
|
||||
expect(task.api).toBe(handler)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
24
src/__tests__/settings-schema.resolvedModelInfo.spec.ts
Normal file
24
src/__tests__/settings-schema.resolvedModelInfo.spec.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { providerSettingsSchema, type ProviderSettings, type ModelInfo, PROVIDER_SETTINGS_KEYS } from "@roo-code/types"
|
||||
|
||||
describe("ProviderSettings schema resolvedModelInfo", () => {
|
||||
it("accepts and preserves resolvedModelInfo", () => {
|
||||
const resolved: ModelInfo = {
|
||||
contextWindow: 16384,
|
||||
supportsPromptCache: true,
|
||||
maxTokens: 8192,
|
||||
}
|
||||
|
||||
const input: ProviderSettings = {
|
||||
apiProvider: "openrouter",
|
||||
openRouterModelId: "openrouter/some-model",
|
||||
resolvedModelInfo: resolved,
|
||||
}
|
||||
|
||||
const parsed = providerSettingsSchema.parse(input)
|
||||
expect(parsed.resolvedModelInfo).toEqual(resolved)
|
||||
})
|
||||
|
||||
it("includes resolvedModelInfo in PROVIDER_SETTINGS_KEYS", () => {
|
||||
expect(PROVIDER_SETTINGS_KEYS).toContain("resolvedModelInfo")
|
||||
})
|
||||
})
|
||||
155
src/__tests__/webviewMessageHandler.refresh.spec.ts
Normal file
155
src/__tests__/webviewMessageHandler.refresh.spec.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import type { ClineProvider } from "../core/webview/ClineProvider"
|
||||
import type { ModelRecord } from "../shared/api"
|
||||
|
||||
vi.mock("../api/providers/fetchers/modelCache", () => ({
|
||||
flushModels: vi.fn(),
|
||||
getModels: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("../api/providers/fetchers/modelEndpointCache", () => ({
|
||||
flushModelProviders: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@roo-code/cloud", () => ({
|
||||
CloudService: {
|
||||
hasInstance: () => false,
|
||||
},
|
||||
}))
|
||||
|
||||
import { webviewMessageHandler } from "../core/webview/webviewMessageHandler"
|
||||
import { flushModels, getModels } from "../api/providers/fetchers/modelCache"
|
||||
import { flushModelProviders } from "../api/providers/fetchers/modelEndpointCache"
|
||||
|
||||
const flushModelsMock = vi.mocked(flushModels)
|
||||
const getModelsMock = vi.mocked(getModels)
|
||||
const flushModelProvidersMock = vi.mocked(flushModelProviders)
|
||||
|
||||
describe("webviewMessageHandler.flushRouterModels", () => {
|
||||
let logSpy: ReturnType<typeof vi.spyOn>
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
logSpy = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("flushes caches, refetches models, persists resolvedModelInfo, and posts success", async () => {
|
||||
const apiConfiguration = {
|
||||
apiProvider: "openrouter",
|
||||
openRouterModelId: "openrouter/model",
|
||||
}
|
||||
const getState = vi.fn().mockResolvedValue({
|
||||
apiConfiguration,
|
||||
currentApiConfigName: "default",
|
||||
})
|
||||
const postMessageToWebview = vi.fn()
|
||||
const upsertProviderProfile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const provider = {
|
||||
getState,
|
||||
postMessageToWebview,
|
||||
upsertProviderProfile,
|
||||
} as unknown as ClineProvider
|
||||
|
||||
const models: ModelRecord = {
|
||||
"openrouter/model": {
|
||||
contextWindow: 32000,
|
||||
maxTokens: 16000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
},
|
||||
}
|
||||
|
||||
getModelsMock.mockResolvedValue(models)
|
||||
|
||||
await webviewMessageHandler(provider, {
|
||||
type: "flushRouterModels",
|
||||
} as any)
|
||||
|
||||
expect(flushModelsMock).toHaveBeenCalledWith("openrouter")
|
||||
expect(flushModelProvidersMock).toHaveBeenCalledWith("openrouter", "openrouter/model")
|
||||
expect(getModelsMock).toHaveBeenCalledWith({ provider: "openrouter" })
|
||||
expect(upsertProviderProfile).toHaveBeenCalledWith(
|
||||
"default",
|
||||
expect.objectContaining({
|
||||
resolvedModelInfo: models["openrouter/model"],
|
||||
}),
|
||||
true,
|
||||
)
|
||||
expect(postMessageToWebview).toHaveBeenCalledWith({ type: "flushRouterModelsResult", success: true })
|
||||
})
|
||||
|
||||
it("supports router overrides supplied via message text when no provider model is selected", async () => {
|
||||
const getState = vi.fn().mockResolvedValue({
|
||||
apiConfiguration: {},
|
||||
currentApiConfigName: undefined,
|
||||
})
|
||||
const postMessageToWebview = vi.fn()
|
||||
const upsertProviderProfile = vi.fn()
|
||||
|
||||
const provider = {
|
||||
getState,
|
||||
postMessageToWebview,
|
||||
upsertProviderProfile,
|
||||
} as unknown as ClineProvider
|
||||
|
||||
getModelsMock.mockResolvedValue({})
|
||||
|
||||
await webviewMessageHandler(provider, {
|
||||
type: "flushRouterModels",
|
||||
text: "requesty",
|
||||
} as any)
|
||||
|
||||
expect(flushModelsMock).toHaveBeenCalledWith("requesty")
|
||||
expect(flushModelProvidersMock).not.toHaveBeenCalled()
|
||||
expect(getModelsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "requesty",
|
||||
}),
|
||||
)
|
||||
expect(upsertProviderProfile).not.toHaveBeenCalled()
|
||||
expect(postMessageToWebview).toHaveBeenCalledWith({ type: "flushRouterModelsResult", success: true })
|
||||
})
|
||||
|
||||
it("posts failure response when refetching models throws", async () => {
|
||||
const apiConfiguration = {
|
||||
apiProvider: "openrouter",
|
||||
openRouterModelId: "openrouter/model",
|
||||
}
|
||||
const getState = vi.fn().mockResolvedValue({
|
||||
apiConfiguration,
|
||||
currentApiConfigName: "default",
|
||||
})
|
||||
const postMessageToWebview = vi.fn()
|
||||
const upsertProviderProfile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const provider = {
|
||||
getState,
|
||||
postMessageToWebview,
|
||||
upsertProviderProfile,
|
||||
} as unknown as ClineProvider
|
||||
|
||||
const failure = new Error("failed to refresh")
|
||||
getModelsMock.mockRejectedValue(failure)
|
||||
|
||||
await webviewMessageHandler(provider, {
|
||||
type: "flushRouterModels",
|
||||
} as any)
|
||||
|
||||
expect(flushModelsMock).toHaveBeenCalledWith("openrouter")
|
||||
expect(flushModelProvidersMock).toHaveBeenCalledWith("openrouter", "openrouter/model")
|
||||
expect(upsertProviderProfile).not.toHaveBeenCalled()
|
||||
expect(postMessageToWebview).toHaveBeenCalledWith({
|
||||
type: "flushRouterModelsResult",
|
||||
success: false,
|
||||
error: failure.message,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -249,11 +249,45 @@ describe("ChutesHandler", () => {
|
|||
apiModelId: testModelId,
|
||||
chutesApiKey: "test-chutes-api-key",
|
||||
})
|
||||
// Note: getModel() returns fallback default without calling fetchModel
|
||||
// Since we haven't called fetchModel, it returns the default chutesDefaultModelId
|
||||
// which is DeepSeek-R1-0528, therefore temperature will be DEEP_SEEK_DEFAULT_TEMPERATURE
|
||||
// With new priority behavior, id stays as requested; non-DeepSeek defaults to 0.5
|
||||
const model = handlerWithModel.getModel()
|
||||
// The default model is DeepSeek-R1, so it returns DEEP_SEEK_DEFAULT_TEMPERATURE
|
||||
expect(model.info.temperature).toBe(DEEP_SEEK_DEFAULT_TEMPERATURE)
|
||||
expect(model.info.temperature).toBe(0.5)
|
||||
})
|
||||
})
|
||||
|
||||
// Phase 2: getModel priority tests
|
||||
describe("ChutesHandler getModel priority", () => {
|
||||
it("prefers options.resolvedModelInfo over cache and default", () => {
|
||||
const resolved = { maxTokens: 1024, contextWindow: 131072, supportsImages: false, supportsPromptCache: false }
|
||||
const handler = new ChutesHandler({
|
||||
chutesApiKey: "k",
|
||||
apiModelId: "deepseek-ai/DeepSeek-R1-0528",
|
||||
resolvedModelInfo: resolved,
|
||||
} as any)
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe("deepseek-ai/DeepSeek-R1-0528")
|
||||
// Info includes resolved fields plus provider temperature decoration
|
||||
expect(model.info).toEqual(expect.objectContaining(resolved))
|
||||
expect(model.info.temperature).toBe(DEEP_SEEK_DEFAULT_TEMPERATURE)
|
||||
})
|
||||
|
||||
it("uses memory cache when no resolvedModelInfo", () => {
|
||||
const handler = new ChutesHandler({ chutesApiKey: "k", apiModelId: "unsloth/Llama-3.3-70B-Instruct" } as any)
|
||||
;(handler as any).models = {
|
||||
"unsloth/Llama-3.3-70B-Instruct": {
|
||||
maxTokens: 2048,
|
||||
contextWindow: 262144,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
}
|
||||
const model = handler.getModel()
|
||||
expect(model.info.maxTokens).toBe(2048)
|
||||
})
|
||||
|
||||
it("falls back to default when neither persisted nor cache", () => {
|
||||
const handler = new ChutesHandler({ chutesApiKey: "k", apiModelId: "unknown/model" } as any)
|
||||
const model = handler.getModel()
|
||||
expect(model.info).toEqual(expect.objectContaining({ contextWindow: expect.any(Number) }))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
62
src/api/providers/__tests__/deepinfra.spec.ts
Normal file
62
src/api/providers/__tests__/deepinfra.spec.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
// npx vitest run api/providers/__tests__/deepinfra.spec.ts
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
|
||||
import { DeepInfraHandler } from "../deepinfra"
|
||||
import type { ApiHandlerOptions } from "../../../shared/api"
|
||||
|
||||
vi.mock("openai", () => ({
|
||||
default: class MockOpenAI {
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
chat = { completions: { create: vi.fn() } }
|
||||
constructor(opts: any) {
|
||||
this.baseURL = opts.baseURL
|
||||
this.apiKey = opts.apiKey
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
describe("DeepInfraHandler getModel priority", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("prefers options.resolvedModelInfo over cache and default", () => {
|
||||
const resolved = { maxTokens: 1234, contextWindow: 56789, supportsImages: false, supportsPromptCache: true }
|
||||
const handler = new DeepInfraHandler({
|
||||
deepInfraApiKey: "k",
|
||||
deepInfraModelId: "meta/llama-3",
|
||||
resolvedModelInfo: resolved,
|
||||
} as any as ApiHandlerOptions)
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe("meta/llama-3")
|
||||
expect(model.info).toBe(resolved)
|
||||
})
|
||||
|
||||
it("uses memory cache when no resolvedModelInfo", () => {
|
||||
const handler = new DeepInfraHandler({
|
||||
deepInfraApiKey: "k",
|
||||
deepInfraModelId: "openai/gpt-4o",
|
||||
} as any as ApiHandlerOptions)
|
||||
;(handler as any).models = {
|
||||
"openai/gpt-4o": {
|
||||
maxTokens: 999,
|
||||
contextWindow: 128000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
}
|
||||
const model = handler.getModel()
|
||||
expect(model.info.maxTokens).toBe(999)
|
||||
})
|
||||
|
||||
it("falls back to default when neither persisted nor cache", () => {
|
||||
const handler = new DeepInfraHandler({
|
||||
deepInfraApiKey: "k",
|
||||
deepInfraModelId: "unknown/model",
|
||||
} as any as ApiHandlerOptions)
|
||||
const model = handler.getModel()
|
||||
expect(model.info).toEqual(expect.objectContaining({ contextWindow: expect.any(Number) }))
|
||||
})
|
||||
})
|
||||
|
|
@ -225,8 +225,44 @@ describe("GlamaHandler", () => {
|
|||
it("should return default model when invalid model provided", async () => {
|
||||
const handlerWithInvalidModel = new GlamaHandler({ ...mockOptions, glamaModelId: "invalid/model" })
|
||||
const modelInfo = await handlerWithInvalidModel.fetchModel()
|
||||
expect(modelInfo.id).toBe("anthropic/claude-3-7-sonnet")
|
||||
// Priority now preserves requested id with default info
|
||||
expect(modelInfo.id).toBe("invalid/model")
|
||||
expect(modelInfo.info).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Phase 2: getModel priority tests
|
||||
describe("GlamaHandler getModel priority", () => {
|
||||
it("prefers options.resolvedModelInfo over cache and default", () => {
|
||||
const resolved = { maxTokens: 1111, contextWindow: 222222, supportsImages: true, supportsPromptCache: true }
|
||||
const handler = new GlamaHandler({
|
||||
glamaApiKey: "k",
|
||||
glamaModelId: "anthropic/claude-3-7-sonnet",
|
||||
resolvedModelInfo: resolved,
|
||||
} as any)
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe("anthropic/claude-3-7-sonnet")
|
||||
expect(model.info).toBe(resolved)
|
||||
})
|
||||
|
||||
it("uses memory cache when no resolvedModelInfo", () => {
|
||||
const handler = new GlamaHandler({ glamaApiKey: "k", glamaModelId: "openai/gpt-4o" } as any)
|
||||
;(handler as any).models = {
|
||||
"openai/gpt-4o": {
|
||||
maxTokens: 3333,
|
||||
contextWindow: 444444,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
}
|
||||
const model = handler.getModel()
|
||||
expect(model.info.maxTokens).toBe(3333)
|
||||
})
|
||||
|
||||
it("falls back to default when neither persisted nor cache", () => {
|
||||
const handler = new GlamaHandler({ glamaApiKey: "k", glamaModelId: "unknown/model" } as any)
|
||||
const model = handler.getModel()
|
||||
expect(model.info).toEqual(expect.objectContaining({ contextWindow: expect.any(Number) }))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -274,6 +274,40 @@ describe("IOIntelligenceHandler", () => {
|
|||
})
|
||||
})
|
||||
|
||||
// Phase 2: getModel priority tests
|
||||
describe("IOIntelligenceHandler getModel priority", () => {
|
||||
it("prefers options.resolvedModelInfo over provider models", () => {
|
||||
const resolved = {
|
||||
maxTokens: 5001,
|
||||
contextWindow: 999999,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
}
|
||||
const handler = new IOIntelligenceHandler({
|
||||
ioIntelligenceApiKey: "k",
|
||||
ioIntelligenceModelId: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8",
|
||||
resolvedModelInfo: resolved,
|
||||
} as any)
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe("meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8")
|
||||
expect(model.info).toBe(resolved)
|
||||
})
|
||||
|
||||
it("uses provider models when no resolvedModelInfo", () => {
|
||||
const handler = new IOIntelligenceHandler({
|
||||
ioIntelligenceApiKey: "k",
|
||||
ioIntelligenceModelId: "openai/gpt-oss-120b",
|
||||
} as any)
|
||||
const model = handler.getModel()
|
||||
expect(model.info).toEqual(
|
||||
expect.objectContaining({
|
||||
contextWindow: expect.any(Number),
|
||||
supportsPromptCache: expect.any(Boolean),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it("should use default model when no model is specified", () => {
|
||||
const handlerWithoutModel = new IOIntelligenceHandler({
|
||||
...mockOptions,
|
||||
|
|
|
|||
|
|
@ -327,8 +327,8 @@ describe("LiteLLMHandler", () => {
|
|||
}
|
||||
handler = new LiteLLMHandler(optionsWithGPT5)
|
||||
|
||||
// Force fetchModel to return undefined maxTokens
|
||||
vi.spyOn(handler as any, "fetchModel").mockResolvedValue({
|
||||
// Force getModel to return undefined maxTokens
|
||||
vi.spyOn(handler as any, "getModel").mockReturnValue({
|
||||
id: "gpt-5",
|
||||
info: { ...litellmDefaultModelInfo, maxTokens: undefined },
|
||||
})
|
||||
|
|
@ -370,8 +370,8 @@ describe("LiteLLMHandler", () => {
|
|||
}
|
||||
handler = new LiteLLMHandler(optionsWithGPT5)
|
||||
|
||||
// Force fetchModel to return undefined maxTokens
|
||||
vi.spyOn(handler as any, "fetchModel").mockResolvedValue({
|
||||
// Force getModel to return undefined maxTokens
|
||||
vi.spyOn(handler as any, "getModel").mockReturnValue({
|
||||
id: "gpt-5",
|
||||
info: { ...litellmDefaultModelInfo, maxTokens: undefined },
|
||||
})
|
||||
|
|
@ -387,4 +387,48 @@ describe("LiteLLMHandler", () => {
|
|||
expect(createCall.max_completion_tokens).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// Phase 2: getModel priority tests
|
||||
describe("LiteLLMHandler getModel priority", () => {
|
||||
it("prefers options.resolvedModelInfo over cache and default", () => {
|
||||
const resolved = {
|
||||
maxTokens: 2468,
|
||||
contextWindow: 135790,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
}
|
||||
const handler = new LiteLLMHandler({
|
||||
litellmApiKey: "k",
|
||||
litellmBaseUrl: "http://localhost:4000",
|
||||
litellmModelId: "gpt-4",
|
||||
resolvedModelInfo: resolved,
|
||||
} as any)
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe("gpt-4")
|
||||
expect(model.info).toBe(resolved)
|
||||
})
|
||||
|
||||
it("uses memory cache when no resolvedModelInfo", () => {
|
||||
const handler = new LiteLLMHandler({
|
||||
litellmApiKey: "k",
|
||||
litellmBaseUrl: "http://localhost:4000",
|
||||
litellmModelId: "llama-3",
|
||||
} as any)
|
||||
;(handler as any).models = {
|
||||
"llama-3": { maxTokens: 5000, contextWindow: 90000, supportsImages: false, supportsPromptCache: false },
|
||||
}
|
||||
const model = handler.getModel()
|
||||
expect(model.info.maxTokens).toBe(5000)
|
||||
})
|
||||
|
||||
it("falls back to default when neither persisted nor cache", () => {
|
||||
const handler = new LiteLLMHandler({
|
||||
litellmApiKey: "k",
|
||||
litellmBaseUrl: "http://localhost:4000",
|
||||
litellmModelId: "unknown/model",
|
||||
} as any)
|
||||
const model = handler.getModel()
|
||||
expect(model.info).toEqual(expect.objectContaining({ contextWindow: expect.any(Number) }))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import OpenAI from "openai"
|
|||
import { OpenRouterHandler } from "../openrouter"
|
||||
import { ApiHandlerOptions } from "../../../shared/api"
|
||||
import { Package } from "../../../shared/package"
|
||||
import { getModels } from "../fetchers/modelCache"
|
||||
import { getModelEndpoints } from "../fetchers/modelEndpointCache"
|
||||
|
||||
// Mock dependencies
|
||||
vitest.mock("openai")
|
||||
|
|
@ -54,6 +56,9 @@ vitest.mock("../fetchers/modelCache", () => ({
|
|||
})
|
||||
}),
|
||||
}))
|
||||
vitest.mock("../fetchers/modelEndpointCache", () => ({
|
||||
getModelEndpoints: vitest.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
describe("OpenRouterHandler", () => {
|
||||
const mockOptions: ApiHandlerOptions = {
|
||||
|
|
@ -78,6 +83,54 @@ describe("OpenRouterHandler", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("getModel priority and caching", () => {
|
||||
it("uses options.resolvedModelInfo when provided (persisted)", () => {
|
||||
const handler = new OpenRouterHandler({
|
||||
openRouterApiKey: "test-key",
|
||||
openRouterModelId: "anthropic/claude-sonnet-4",
|
||||
resolvedModelInfo: {
|
||||
maxTokens: 12345,
|
||||
contextWindow: 99999,
|
||||
supportsPromptCache: false,
|
||||
} as any,
|
||||
})
|
||||
|
||||
const logSpy = vitest.spyOn(console, "log").mockImplementation(() => {})
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe("anthropic/claude-sonnet-4")
|
||||
expect(model.info.maxTokens).toBe(12345)
|
||||
expect(logSpy).toHaveBeenCalledWith("[model-cache] source:", "persisted")
|
||||
logSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("falls back to memory cache when persisted is absent", () => {
|
||||
const handler = new OpenRouterHandler({
|
||||
openRouterApiKey: "test-key",
|
||||
openRouterModelId: "custom/model",
|
||||
})
|
||||
;(handler as any).models = {
|
||||
"custom/model": { maxTokens: 7777, contextWindow: 42424, supportsPromptCache: false },
|
||||
}
|
||||
|
||||
const logSpy = vitest.spyOn(console, "log").mockImplementation(() => {})
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe("custom/model")
|
||||
expect(model.info.maxTokens).toBe(7777)
|
||||
expect(logSpy).toHaveBeenCalledWith("[model-cache] source:", "memory-cache")
|
||||
logSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("falls back to openRouterDefaultModelInfo when both are absent", () => {
|
||||
const handler = new OpenRouterHandler({})
|
||||
const logSpy = vitest.spyOn(console, "log").mockImplementation(() => {})
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe("anthropic/claude-sonnet-4")
|
||||
expect(model.info.supportsPromptCache).toBe(true)
|
||||
expect(logSpy).toHaveBeenCalledWith("[model-cache] source:", "default-fallback")
|
||||
logSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetchModel", () => {
|
||||
it("returns correct model info when options are provided", async () => {
|
||||
const handler = new OpenRouterHandler(mockOptions)
|
||||
|
|
|
|||
|
|
@ -237,3 +237,32 @@ describe("RequestyHandler", () => {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Phase 2: getModel priority tests
|
||||
describe("RequestyHandler getModel priority", () => {
|
||||
it("prefers options.resolvedModelInfo over cache and default", () => {
|
||||
const resolved = { maxTokens: 1234, contextWindow: 55555, supportsImages: false, supportsPromptCache: true }
|
||||
const handler = new RequestyHandler({
|
||||
requestyModelId: "coding/claude-4-sonnet",
|
||||
resolvedModelInfo: resolved,
|
||||
} as any)
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe("coding/claude-4-sonnet")
|
||||
expect(model.info).toBe(resolved)
|
||||
})
|
||||
|
||||
it("uses memory cache when no resolvedModelInfo", () => {
|
||||
const handler = new RequestyHandler({ requestyModelId: "router/model" } as any)
|
||||
;(handler as any).models = {
|
||||
"router/model": { maxTokens: 2345, contextWindow: 64000, supportsImages: true, supportsPromptCache: false },
|
||||
}
|
||||
const model = handler.getModel()
|
||||
expect(model.info.maxTokens).toBe(2345)
|
||||
})
|
||||
|
||||
it("falls back to default when neither persisted nor cache", () => {
|
||||
const handler = new RequestyHandler({ requestyModelId: "unknown/model" } as any)
|
||||
const model = handler.getModel()
|
||||
expect(model.info).toEqual(expect.objectContaining({ contextWindow: expect.any(Number) }))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -630,4 +630,49 @@ describe("RooHandler", () => {
|
|||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Phase 2: getModel priority tests
|
||||
describe("RooHandler getModel priority", () => {
|
||||
it("prefers options.resolvedModelInfo over shared cache and default", () => {
|
||||
const resolved = {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 131072,
|
||||
supportsImages: false,
|
||||
supportsReasoningEffort: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
}
|
||||
const handler = new RooHandler({
|
||||
apiModelId: "xai/grok-code-fast-1",
|
||||
resolvedModelInfo: resolved,
|
||||
} as any)
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe("xai/grok-code-fast-1")
|
||||
expect(model.info).toBe(resolved)
|
||||
})
|
||||
|
||||
it("uses shared cache when no resolvedModelInfo", async () => {
|
||||
const handler = new RooHandler({ apiModelId: "xai/grok-code-fast-1" } as any)
|
||||
const model = handler.getModel()
|
||||
expect(model.info).toEqual(
|
||||
expect.objectContaining({
|
||||
contextWindow: 262144,
|
||||
supportsPromptCache: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("falls back to default when neither persisted nor cache", () => {
|
||||
const handler = new RooHandler({ apiModelId: "unknown/model" } as any)
|
||||
const model = handler.getModel()
|
||||
expect(model.info).toEqual(
|
||||
expect.objectContaining({
|
||||
maxTokens: 16384,
|
||||
contextWindow: 262144,
|
||||
supportsPromptCache: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -324,8 +324,44 @@ describe("UnboundHandler", () => {
|
|||
it("should return default model when invalid model provided", async () => {
|
||||
const handlerWithInvalidModel = new UnboundHandler({ ...mockOptions, unboundModelId: "invalid/model" })
|
||||
const modelInfo = await handlerWithInvalidModel.fetchModel()
|
||||
expect(modelInfo.id).toBe("anthropic/claude-sonnet-4-5")
|
||||
// Priority now preserves requested id with default info
|
||||
expect(modelInfo.id).toBe("invalid/model")
|
||||
expect(modelInfo.info).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Phase 2: getModel priority tests
|
||||
describe("UnboundHandler getModel priority", () => {
|
||||
it("prefers options.resolvedModelInfo over cache and default", () => {
|
||||
const resolved = { maxTokens: 7777, contextWindow: 888888, supportsImages: false, supportsPromptCache: true }
|
||||
const handler = new UnboundHandler({
|
||||
unboundApiKey: "k",
|
||||
unboundModelId: "anthropic/claude-3-5-sonnet-20241022",
|
||||
resolvedModelInfo: resolved,
|
||||
} as any)
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe("anthropic/claude-3-5-sonnet-20241022")
|
||||
expect(model.info).toBe(resolved)
|
||||
})
|
||||
|
||||
it("uses memory cache when no resolvedModelInfo", () => {
|
||||
const handler = new UnboundHandler({ unboundApiKey: "k", unboundModelId: "openai/gpt-4o" } as any)
|
||||
;(handler as any).models = {
|
||||
"openai/gpt-4o": {
|
||||
maxTokens: 9999,
|
||||
contextWindow: 128000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
}
|
||||
const model = handler.getModel()
|
||||
expect(model.info.maxTokens).toBe(9999)
|
||||
})
|
||||
|
||||
it("falls back to default when neither persisted nor cache", () => {
|
||||
const handler = new UnboundHandler({ unboundApiKey: "k", unboundModelId: "unknown/model" } as any)
|
||||
const model = handler.getModel()
|
||||
expect(model.info).toEqual(expect.objectContaining({ contextWindow: expect.any(Number) }))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -359,6 +359,52 @@ describe("VercelAiGatewayHandler", () => {
|
|||
})
|
||||
})
|
||||
|
||||
// Phase 2: getModel priority tests
|
||||
describe("VercelAiGatewayHandler getModel priority", () => {
|
||||
it("prefers options.resolvedModelInfo over cache and default", () => {
|
||||
const resolved = {
|
||||
maxTokens: 64000,
|
||||
contextWindow: 200000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
}
|
||||
const handler = new VercelAiGatewayHandler({
|
||||
vercelAiGatewayApiKey: "k",
|
||||
vercelAiGatewayModelId: "anthropic/claude-sonnet-4",
|
||||
resolvedModelInfo: resolved,
|
||||
} as any)
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe("anthropic/claude-sonnet-4")
|
||||
expect(model.info).toBe(resolved)
|
||||
})
|
||||
|
||||
it("uses memory cache when no resolvedModelInfo", () => {
|
||||
const handler = new VercelAiGatewayHandler({
|
||||
vercelAiGatewayApiKey: "k",
|
||||
vercelAiGatewayModelId: "openai/gpt-4o",
|
||||
} as any)
|
||||
;(handler as any).models = {
|
||||
"openai/gpt-4o": {
|
||||
maxTokens: 16000,
|
||||
contextWindow: 128000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
}
|
||||
const model = handler.getModel()
|
||||
expect(model.info.maxTokens).toBe(16000)
|
||||
})
|
||||
|
||||
it("falls back to default when neither persisted nor cache", () => {
|
||||
const handler = new VercelAiGatewayHandler({
|
||||
vercelAiGatewayApiKey: "k",
|
||||
vercelAiGatewayModelId: "unknown/model",
|
||||
} as any)
|
||||
const model = handler.getModel()
|
||||
expect(model.info).toEqual(expect.objectContaining({ contextWindow: expect.any(Number) }))
|
||||
})
|
||||
})
|
||||
|
||||
describe("temperature support", () => {
|
||||
it("applies temperature for supported models", async () => {
|
||||
const handler = new VercelAiGatewayHandler({
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export class ChutesHandler extends RouterProvider implements SingleCompletionHan
|
|||
messages: Anthropic.Messages.MessageParam[],
|
||||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): ApiStream {
|
||||
const model = await this.fetchModel()
|
||||
const model = this.getModel()
|
||||
|
||||
if (model.id.includes("DeepSeek-R1")) {
|
||||
const stream = await this.client.chat.completions.create({
|
||||
|
|
@ -127,7 +127,7 @@ export class ChutesHandler extends RouterProvider implements SingleCompletionHan
|
|||
}
|
||||
|
||||
async completePrompt(prompt: string): Promise<string> {
|
||||
const model = await this.fetchModel()
|
||||
const model = this.getModel()
|
||||
const { id: modelId, info } = model
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -40,7 +40,11 @@ export class DeepInfraHandler extends RouterProvider implements SingleCompletion
|
|||
|
||||
override getModel() {
|
||||
const id = this.options.deepInfraModelId ?? deepInfraDefaultModelId
|
||||
const info = this.models[id] ?? deepInfraDefaultModelInfo
|
||||
const info = this.options.resolvedModelInfo ?? this.models[id] ?? deepInfraDefaultModelInfo
|
||||
console.log(
|
||||
"[model-cache] source:",
|
||||
this.options.resolvedModelInfo ? "persisted" : this.models[id] ? "memory-cache" : "default-fallback",
|
||||
)
|
||||
|
||||
const params = getModelParams({
|
||||
format: "openai",
|
||||
|
|
@ -57,9 +61,8 @@ export class DeepInfraHandler extends RouterProvider implements SingleCompletion
|
|||
messages: Anthropic.Messages.MessageParam[],
|
||||
_metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): ApiStream {
|
||||
// Ensure we have up-to-date model metadata
|
||||
await this.fetchModel()
|
||||
const { id: modelId, info, reasoningEffort: reasoning_effort } = await this.fetchModel()
|
||||
// Use current model metadata synchronously
|
||||
const { id: modelId, info, reasoningEffort: reasoning_effort } = this.getModel()
|
||||
let prompt_cache_key = undefined
|
||||
if (info.supportsPromptCache && _metadata?.taskId) {
|
||||
prompt_cache_key = _metadata.taskId
|
||||
|
|
@ -107,7 +110,6 @@ export class DeepInfraHandler extends RouterProvider implements SingleCompletion
|
|||
}
|
||||
|
||||
async completePrompt(prompt: string): Promise<string> {
|
||||
await this.fetchModel()
|
||||
const { id: modelId, info } = this.getModel()
|
||||
|
||||
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
|
||||
|
|
|
|||
|
|
@ -12,10 +12,25 @@ vi.mock("node-cache", () => {
|
|||
})
|
||||
|
||||
// Mock fs/promises to avoid file system operations
|
||||
vi.mock("fs/promises", () => ({
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
readFile: vi.fn().mockResolvedValue("{}"),
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
vi.mock("fs/promises", () => {
|
||||
const mod = {
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
readFile: vi.fn().mockResolvedValue("{}"),
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
// Default to "file exists"; individual tests will override readFile content as needed
|
||||
access: vi.fn().mockResolvedValue(undefined),
|
||||
unlink: vi.fn().mockResolvedValue(undefined),
|
||||
rename: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
return { ...mod, default: mod }
|
||||
})
|
||||
|
||||
// Provide stable paths for caches during tests
|
||||
vi.mock("../../../../core/config/ContextProxy", () => ({
|
||||
ContextProxy: { instance: { globalStorageUri: { fsPath: "/tmp" } } },
|
||||
}))
|
||||
vi.mock("../../../../utils/storage", () => ({
|
||||
getCacheDirectoryPath: vi.fn().mockResolvedValue("/tmp/cache"),
|
||||
}))
|
||||
|
||||
// Mock all the model fetchers
|
||||
|
|
@ -28,7 +43,8 @@ vi.mock("../io-intelligence")
|
|||
|
||||
// Then imports
|
||||
import type { Mock } from "vitest"
|
||||
import { getModels } from "../modelCache"
|
||||
import { getModels, flushModels } from "../modelCache"
|
||||
import { flushModelProviders } from "../modelEndpointCache"
|
||||
import { getLiteLLMModels } from "../litellm"
|
||||
import { getOpenRouterModels } from "../openrouter"
|
||||
import { getRequestyModels } from "../requesty"
|
||||
|
|
@ -48,8 +64,22 @@ const DUMMY_UNBOUND_KEY = "unbound-key-for-testing"
|
|||
const DUMMY_IOINTELLIGENCE_KEY = "io-intelligence-key-for-testing"
|
||||
|
||||
describe("getModels with new GetModelsOptions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks()
|
||||
|
||||
// Re-prime mocked storage/helper modules after resetAllMocks clears implementations
|
||||
const storage = await import("../../../../utils/storage")
|
||||
;(storage.getCacheDirectoryPath as unknown as Mock).mockResolvedValue("/tmp/cache")
|
||||
|
||||
const ctx = await import("../../../../core/config/ContextProxy")
|
||||
;(ctx as any).ContextProxy = { instance: { globalStorageUri: { fsPath: "/tmp" } } }
|
||||
|
||||
// Ensure memory cache does not leak across tests
|
||||
await Promise.all(
|
||||
["litellm", "openrouter", "requesty", "glama", "unbound", "io-intelligence"].map((r) =>
|
||||
flushModels(r as any),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it("calls getLiteLLMModels with correct parameters", async () => {
|
||||
|
|
@ -63,6 +93,9 @@ describe("getModels with new GetModelsOptions", () => {
|
|||
}
|
||||
mockGetLiteLLMModels.mockResolvedValue(mockModels)
|
||||
|
||||
const fsp = await import("fs/promises")
|
||||
;(fsp.readFile as unknown as Mock).mockResolvedValueOnce(JSON.stringify(mockModels))
|
||||
|
||||
const result = await getModels({
|
||||
provider: "litellm",
|
||||
apiKey: "test-api-key",
|
||||
|
|
@ -84,6 +117,9 @@ describe("getModels with new GetModelsOptions", () => {
|
|||
}
|
||||
mockGetOpenRouterModels.mockResolvedValue(mockModels)
|
||||
|
||||
const fsp = await import("fs/promises")
|
||||
;(fsp.readFile as unknown as Mock).mockResolvedValueOnce(JSON.stringify(mockModels))
|
||||
|
||||
const result = await getModels({ provider: "openrouter" })
|
||||
|
||||
expect(mockGetOpenRouterModels).toHaveBeenCalled()
|
||||
|
|
@ -101,6 +137,9 @@ describe("getModels with new GetModelsOptions", () => {
|
|||
}
|
||||
mockGetRequestyModels.mockResolvedValue(mockModels)
|
||||
|
||||
const fsp = await import("fs/promises")
|
||||
;(fsp.readFile as unknown as Mock).mockResolvedValueOnce(JSON.stringify(mockModels))
|
||||
|
||||
const result = await getModels({ provider: "requesty", apiKey: DUMMY_REQUESTY_KEY })
|
||||
|
||||
expect(mockGetRequestyModels).toHaveBeenCalledWith(undefined, DUMMY_REQUESTY_KEY)
|
||||
|
|
@ -118,6 +157,9 @@ describe("getModels with new GetModelsOptions", () => {
|
|||
}
|
||||
mockGetGlamaModels.mockResolvedValue(mockModels)
|
||||
|
||||
const fsp = await import("fs/promises")
|
||||
;(fsp.readFile as unknown as Mock).mockResolvedValueOnce(JSON.stringify(mockModels))
|
||||
|
||||
const result = await getModels({ provider: "glama" })
|
||||
|
||||
expect(mockGetGlamaModels).toHaveBeenCalled()
|
||||
|
|
@ -135,6 +177,9 @@ describe("getModels with new GetModelsOptions", () => {
|
|||
}
|
||||
mockGetUnboundModels.mockResolvedValue(mockModels)
|
||||
|
||||
const fsp = await import("fs/promises")
|
||||
;(fsp.readFile as unknown as Mock).mockResolvedValueOnce(JSON.stringify(mockModels))
|
||||
|
||||
const result = await getModels({ provider: "unbound", apiKey: DUMMY_UNBOUND_KEY })
|
||||
|
||||
expect(mockGetUnboundModels).toHaveBeenCalledWith(DUMMY_UNBOUND_KEY)
|
||||
|
|
@ -152,13 +197,111 @@ describe("getModels with new GetModelsOptions", () => {
|
|||
}
|
||||
mockGetIOIntelligenceModels.mockResolvedValue(mockModels)
|
||||
|
||||
const fsp = await import("fs/promises")
|
||||
;(fsp.readFile as unknown as Mock).mockResolvedValueOnce(JSON.stringify(mockModels))
|
||||
|
||||
const result = await getModels({ provider: "io-intelligence", apiKey: DUMMY_IOINTELLIGENCE_KEY })
|
||||
|
||||
expect(mockGetIOIntelligenceModels).toHaveBeenCalled()
|
||||
expect(result).toEqual(mockModels)
|
||||
})
|
||||
|
||||
describe("explicit flush and no auto-expiration", () => {
|
||||
it("flushModels clears memory and attempts to delete file cache", async () => {
|
||||
const fsUtils = await import("../../../../utils/fs")
|
||||
const existsSpy = vi.spyOn(fsUtils, "fileExistsAtPath").mockResolvedValue(true)
|
||||
|
||||
const fsp = await import("fs/promises")
|
||||
const def = (fsp as any).default ?? (fsp as any)
|
||||
const unlink = def.unlink as unknown as Mock
|
||||
unlink.mockClear()
|
||||
|
||||
// Act
|
||||
await flushModels("openrouter")
|
||||
|
||||
// Assert file deletion attempted with expected filename pattern
|
||||
expect(unlink).toHaveBeenCalled()
|
||||
const [[calledPath]] = (unlink as unknown as { mock: { calls: [string][] } }).mock.calls
|
||||
expect(String(calledPath)).toContain("openrouter_models.json")
|
||||
|
||||
existsSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("flushModelProviders clears memory and attempts to delete endpoints file cache", async () => {
|
||||
const fsUtils = await import("../../../../utils/fs")
|
||||
const existsSpy = vi.spyOn(fsUtils, "fileExistsAtPath").mockResolvedValue(true)
|
||||
|
||||
const fsp = await import("fs/promises")
|
||||
const def = (fsp as any).default ?? (fsp as any)
|
||||
const unlink = def.unlink as unknown as Mock
|
||||
unlink.mockClear()
|
||||
|
||||
await flushModelProviders("openrouter", "test-model")
|
||||
|
||||
// Assert endpoints file deletion attempted with expected filename pattern
|
||||
expect(unlink).toHaveBeenCalled()
|
||||
const calls = (unlink as any).mock.calls.map((c: any[]) => String(c[0]))
|
||||
expect(calls.some((p: string) => p.includes("openrouter_test-model_endpoints.json"))).toBe(true)
|
||||
|
||||
existsSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("does not auto-expire cached entries after previous TTL window", async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.resetModules()
|
||||
|
||||
// Use real NodeCache for this re-import
|
||||
vi.unmock("node-cache")
|
||||
|
||||
const expectedModels = {
|
||||
"test/model": {
|
||||
maxTokens: 1024,
|
||||
contextWindow: 8192,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Lightweight mocks to avoid real FS and VSCode context
|
||||
vi.doMock("fs/promises", () => ({
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
readFile: vi.fn().mockResolvedValue(JSON.stringify(expectedModels)),
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
access: vi.fn().mockResolvedValue(undefined),
|
||||
unlink: vi.fn().mockResolvedValue(undefined),
|
||||
rename: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
vi.doMock("../../../../utils/safeWriteJson", () => ({
|
||||
safeWriteJson: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
vi.doMock("../../../../core/config/ContextProxy", () => ({
|
||||
ContextProxy: { instance: { globalStorageUri: { fsPath: "/tmp" } } },
|
||||
}))
|
||||
vi.doMock("../../../../utils/storage", () => ({
|
||||
getCacheDirectoryPath: vi.fn().mockResolvedValue("/tmp/cache"),
|
||||
}))
|
||||
vi.doMock("../openrouter", () => ({
|
||||
getOpenRouterModels: vi.fn().mockResolvedValue(expectedModels),
|
||||
}))
|
||||
|
||||
const { getModels, getModelsFromCache } = await import("../modelCache")
|
||||
|
||||
await getModels({ provider: "openrouter" })
|
||||
expect(getModelsFromCache("openrouter")).toEqual(expectedModels)
|
||||
|
||||
// Advance beyond the old TTL (5 minutes)
|
||||
vi.advanceTimersByTime(6 * 60 * 1000)
|
||||
|
||||
// Value should still be present (no auto-expiry)
|
||||
expect(getModelsFromCache("openrouter")).toEqual(expectedModels)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
it("handles errors and re-throws them", async () => {
|
||||
// Ensure no leftover implementation from previous tests
|
||||
mockGetLiteLLMModels.mockReset()
|
||||
|
||||
const expectedError = new Error("LiteLLM connection failed")
|
||||
mockGetLiteLLMModels.mockRejectedValue(expectedError)
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import { getHuggingFaceModels } from "./huggingface"
|
|||
import { getRooModels } from "./roo"
|
||||
import { getChutesModels } from "./chutes"
|
||||
|
||||
const memoryCache = new NodeCache({ stdTTL: 5 * 60, checkperiod: 5 * 60 })
|
||||
const memoryCache = new NodeCache({ stdTTL: 0, checkperiod: 5 * 60 })
|
||||
|
||||
async function writeModels(router: RouterName, data: ModelRecord) {
|
||||
const filename = `${router}_models.json`
|
||||
|
|
@ -145,7 +145,19 @@ export const getModels = async (options: GetModelsOptions): Promise<ModelRecord>
|
|||
* @param router - The router to flush models for.
|
||||
*/
|
||||
export const flushModels = async (router: RouterName) => {
|
||||
// Clear in-memory cache
|
||||
memoryCache.del(router)
|
||||
|
||||
// Best-effort delete of persisted file cache (ignore ENOENT)
|
||||
try {
|
||||
const filename = `${router}_models.json`
|
||||
const cacheDir = await getCacheDirectoryPath(ContextProxy.instance.globalStorageUri.fsPath)
|
||||
const filePath = path.join(cacheDir, filename)
|
||||
|
||||
await fs.unlink(filePath).catch(() => {})
|
||||
} catch (err) {
|
||||
console.error(`[flushModels] failed to delete persisted cache for ${router}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
export function getModelsFromCache(provider: ProviderName) {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { fileExistsAtPath } from "../../../utils/fs"
|
|||
|
||||
import { getOpenRouterModelEndpoints } from "./openrouter"
|
||||
|
||||
const memoryCache = new NodeCache({ stdTTL: 5 * 60, checkperiod: 5 * 60 })
|
||||
const memoryCache = new NodeCache({ stdTTL: 0, checkperiod: 5 * 60 })
|
||||
|
||||
const getCacheKey = (router: RouterName, modelId: string) => sanitize(`${router}_${modelId}`)
|
||||
|
||||
|
|
@ -79,5 +79,19 @@ export const getModelEndpoints = async ({
|
|||
return modelProviders ?? {}
|
||||
}
|
||||
|
||||
export const flushModelProviders = async (router: RouterName, modelId: string) =>
|
||||
memoryCache.del(getCacheKey(router, modelId))
|
||||
export const flushModelProviders = async (router: RouterName, modelId: string) => {
|
||||
// Clear in-memory cache for this (router, modelId) key
|
||||
const key = getCacheKey(router, modelId)
|
||||
memoryCache.del(key)
|
||||
|
||||
// Best-effort delete of persisted file cache (ignore ENOENT)
|
||||
try {
|
||||
const filename = `${key}_endpoints.json`
|
||||
const cacheDir = await getCacheDirectoryPath(ContextProxy.instance.globalStorageUri.fsPath)
|
||||
const filePath = path.join(cacheDir, filename)
|
||||
|
||||
await fs.unlink(filePath).catch(() => {})
|
||||
} catch (err) {
|
||||
console.error(`[flushModelProviders] failed to delete persisted endpoints cache for ${key}:`, err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,6 +115,9 @@ export async function getOpenRouterModels(options?: ApiHandlerOptions): Promise<
|
|||
continue
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[openrouter] fetched model ${id}: context_length=${model.context_length}, max_completion_tokens=${top_provider?.max_completion_tokens ?? "n/a"}`,
|
||||
)
|
||||
models[id] = parseOpenRouterModel({
|
||||
id,
|
||||
model,
|
||||
|
|
@ -161,6 +164,9 @@ export async function getOpenRouterModelEndpoints(
|
|||
}
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
console.log(
|
||||
`[openrouter] fetched model ${id} endpoint ${endpoint.tag ?? endpoint.provider_name}: context_length=${endpoint.context_length}, max_completion_tokens=${endpoint.max_completion_tokens ?? "n/a"}`,
|
||||
)
|
||||
models[endpoint.tag ?? endpoint.provider_name] = parseOpenRouterModel({
|
||||
id,
|
||||
model: endpoint,
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export class GlamaHandler extends RouterProvider implements SingleCompletionHand
|
|||
messages: Anthropic.Messages.MessageParam[],
|
||||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): ApiStream {
|
||||
const { id: modelId, info } = await this.fetchModel()
|
||||
const { id: modelId, info } = this.getModel()
|
||||
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
|
|
@ -117,7 +117,7 @@ export class GlamaHandler extends RouterProvider implements SingleCompletionHand
|
|||
}
|
||||
|
||||
async completePrompt(prompt: string): Promise<string> {
|
||||
const { id: modelId, info } = await this.fetchModel()
|
||||
const { id: modelId, info } = this.getModel()
|
||||
|
||||
try {
|
||||
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
|
||||
|
|
|
|||
|
|
@ -23,14 +23,25 @@ export class IOIntelligenceHandler extends BaseOpenAiCompatibleProvider<IOIntell
|
|||
override getModel() {
|
||||
const modelId = this.options.ioIntelligenceModelId || (ioIntelligenceDefaultModelId as IOIntelligenceModelId)
|
||||
|
||||
const modelInfo =
|
||||
this.providerModels[modelId as IOIntelligenceModelId] ?? this.providerModels[ioIntelligenceDefaultModelId]
|
||||
const info =
|
||||
(this.options.resolvedModelInfo as any) ??
|
||||
this.providerModels[modelId as IOIntelligenceModelId] ??
|
||||
this.providerModels[ioIntelligenceDefaultModelId]
|
||||
|
||||
if (modelInfo) {
|
||||
return { id: modelId as IOIntelligenceModelId, info: modelInfo }
|
||||
console.log(
|
||||
"[model-cache] source:",
|
||||
this.options.resolvedModelInfo
|
||||
? "persisted"
|
||||
: this.providerModels[modelId as IOIntelligenceModelId]
|
||||
? "memory-cache"
|
||||
: "default-fallback",
|
||||
)
|
||||
|
||||
if (info) {
|
||||
return { id: modelId as IOIntelligenceModelId, info }
|
||||
}
|
||||
|
||||
// Return the requested model ID even if not found, with fallback info.
|
||||
// Fallback safety
|
||||
return {
|
||||
id: modelId as IOIntelligenceModelId,
|
||||
info: {
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa
|
|||
messages: Anthropic.Messages.MessageParam[],
|
||||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): ApiStream {
|
||||
const { id: modelId, info } = await this.fetchModel()
|
||||
const { id: modelId, info } = this.getModel()
|
||||
|
||||
const openAiMessages = convertToOpenAiMessages(messages)
|
||||
|
||||
|
|
@ -193,7 +193,7 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa
|
|||
}
|
||||
|
||||
async completePrompt(prompt: string): Promise<string> {
|
||||
const { id: modelId, info } = await this.fetchModel()
|
||||
const { id: modelId, info } = this.getModel()
|
||||
|
||||
// Check if this is a GPT-5 model that requires max_completion_tokens instead of max_tokens
|
||||
const isGPT5Model = this.isGpt5(modelId)
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
|
|||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
): AsyncGenerator<ApiStreamChunk> {
|
||||
const model = await this.fetchModel()
|
||||
const model = this.getModel()
|
||||
|
||||
let { id: modelId, maxTokens, temperature, topP, reasoning } = model
|
||||
|
||||
|
|
@ -225,7 +225,15 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
|
|||
|
||||
override getModel() {
|
||||
const id = this.options.openRouterModelId ?? openRouterDefaultModelId
|
||||
let info = this.models[id] ?? openRouterDefaultModelInfo
|
||||
|
||||
// Priority: 1) persisted (resolvedModelInfo), 2) memory cache, 3) default fallback
|
||||
console.log(
|
||||
"[model-cache] source:",
|
||||
this.options.resolvedModelInfo ? "persisted" : this.models[id] ? "memory-cache" : "default-fallback",
|
||||
)
|
||||
|
||||
let info =
|
||||
(this.options.resolvedModelInfo as any) ?? (this.models[id] as any) ?? (openRouterDefaultModelInfo as any)
|
||||
|
||||
// If a specific provider is requested, use the endpoint for that provider.
|
||||
if (this.options.openRouterSpecificProvider && this.endpoints[this.options.openRouterSpecificProvider]) {
|
||||
|
|
@ -246,7 +254,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
|
|||
}
|
||||
|
||||
async completePrompt(prompt: string) {
|
||||
let { id: modelId, maxTokens, temperature, reasoning } = await this.fetchModel()
|
||||
let { id: modelId, maxTokens, temperature, reasoning } = this.getModel()
|
||||
|
||||
const completionParams: OpenRouterChatCompletionParams = {
|
||||
model: modelId,
|
||||
|
|
|
|||
|
|
@ -67,7 +67,11 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan
|
|||
|
||||
override getModel() {
|
||||
const id = this.options.requestyModelId ?? requestyDefaultModelId
|
||||
const info = this.models[id] ?? requestyDefaultModelInfo
|
||||
const info = this.options.resolvedModelInfo ?? this.models[id] ?? requestyDefaultModelInfo
|
||||
console.log(
|
||||
"[model-cache] source:",
|
||||
this.options.resolvedModelInfo ? "persisted" : this.models[id] ? "memory-cache" : "default-fallback",
|
||||
)
|
||||
|
||||
const params = getModelParams({
|
||||
format: "anthropic",
|
||||
|
|
@ -111,7 +115,7 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan
|
|||
temperature,
|
||||
reasoningEffort: reasoning_effort,
|
||||
reasoning: thinking,
|
||||
} = await this.fetchModel()
|
||||
} = this.getModel()
|
||||
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
|
|
@ -160,7 +164,7 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan
|
|||
}
|
||||
|
||||
async completePrompt(prompt: string): Promise<string> {
|
||||
const { id: model, maxTokens: max_tokens, temperature } = await this.fetchModel()
|
||||
const { id: model, maxTokens: max_tokens, temperature } = this.getModel()
|
||||
|
||||
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [{ role: "system", content: prompt }]
|
||||
|
||||
|
|
|
|||
|
|
@ -194,15 +194,23 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
|
|||
override getModel() {
|
||||
const modelId = this.options.apiModelId || rooDefaultModelId
|
||||
|
||||
// Get models from shared cache
|
||||
// 1) Persisted
|
||||
if (this.options.resolvedModelInfo) {
|
||||
console.log("[model-cache] source:", "persisted")
|
||||
return { id: modelId, info: this.options.resolvedModelInfo }
|
||||
}
|
||||
|
||||
// 2) Shared cache
|
||||
const models = getModelsFromCache("roo") || {}
|
||||
const modelInfo = models[modelId]
|
||||
|
||||
console.log("[model-cache] source:", modelInfo ? "memory-cache" : "default-fallback")
|
||||
|
||||
if (modelInfo) {
|
||||
return { id: modelId, info: modelInfo }
|
||||
}
|
||||
|
||||
// Return the requested model ID even if not found, with fallback info.
|
||||
// 3) Fallback defaults
|
||||
return {
|
||||
id: modelId,
|
||||
info: {
|
||||
|
|
|
|||
|
|
@ -62,10 +62,12 @@ export abstract class RouterProvider extends BaseProvider {
|
|||
|
||||
override getModel(): { id: string; info: ModelInfo } {
|
||||
const id = this.modelId ?? this.defaultModelId
|
||||
|
||||
return this.models[id]
|
||||
? { id, info: this.models[id] }
|
||||
: { id: this.defaultModelId, info: this.defaultModelInfo }
|
||||
const info = this.options.resolvedModelInfo ?? this.models[id] ?? this.defaultModelInfo
|
||||
console.log(
|
||||
"[model-cache] source:",
|
||||
this.options.resolvedModelInfo ? "persisted" : this.models[id] ? "memory-cache" : "default-fallback",
|
||||
)
|
||||
return { id, info }
|
||||
}
|
||||
|
||||
protected supportsTemperature(modelId: string): boolean {
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ export class UnboundHandler extends RouterProvider implements SingleCompletionHa
|
|||
messages: Anthropic.Messages.MessageParam[],
|
||||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): ApiStream {
|
||||
const { id: modelId, info } = await this.fetchModel()
|
||||
const { id: modelId, info } = this.getModel()
|
||||
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
|
|
@ -133,7 +133,7 @@ export class UnboundHandler extends RouterProvider implements SingleCompletionHa
|
|||
}
|
||||
|
||||
async completePrompt(prompt: string): Promise<string> {
|
||||
const { id: modelId, info } = await this.fetchModel()
|
||||
const { id: modelId, info } = this.getModel()
|
||||
|
||||
try {
|
||||
const requestOptions: UnboundChatCompletionCreateParamsNonStreaming = {
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export class VercelAiGatewayHandler extends RouterProvider implements SingleComp
|
|||
messages: Anthropic.Messages.MessageParam[],
|
||||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): ApiStream {
|
||||
const { id: modelId, info } = await this.fetchModel()
|
||||
const { id: modelId, info } = this.getModel()
|
||||
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
|
|
@ -88,7 +88,7 @@ export class VercelAiGatewayHandler extends RouterProvider implements SingleComp
|
|||
}
|
||||
|
||||
async completePrompt(prompt: string): Promise<string> {
|
||||
const { id: modelId, info } = await this.fetchModel()
|
||||
const { id: modelId, info } = this.getModel()
|
||||
|
||||
try {
|
||||
const requestOptions: OpenAI.Chat.ChatCompletionCreateParams = {
|
||||
|
|
|
|||
|
|
@ -142,8 +142,17 @@ export async function truncateConversationIfNeeded({
|
|||
}
|
||||
// If no specific threshold is found for the profile, fall back to global setting
|
||||
|
||||
// Debug: log context window and thresholds for sliding-window checks
|
||||
console.log(
|
||||
`[sliding-window] check: contextWindow=${contextWindow}, prevContextTokens=${prevContextTokens}, reservedTokens=${reservedTokens}, allowedTokens=${allowedTokens}, effectiveThreshold=${effectiveThreshold}, autoCondenseContext=${autoCondenseContext}`,
|
||||
)
|
||||
|
||||
if (autoCondenseContext) {
|
||||
const contextPercent = (100 * prevContextTokens) / contextWindow
|
||||
// Debug: log auto-condense threshold check with current context window
|
||||
console.log(
|
||||
`[sliding-window] auto-condense check: contextWindow=${contextWindow}, contextPercent=${contextPercent.toFixed(2)}%, threshold=${effectiveThreshold}%, allowedTokens=${allowedTokens}`,
|
||||
)
|
||||
if (contextPercent >= effectiveThreshold || prevContextTokens > allowedTokens) {
|
||||
// Attempt to intelligently condense the context
|
||||
const result = await summarizeConversation(
|
||||
|
|
@ -166,6 +175,10 @@ export async function truncateConversationIfNeeded({
|
|||
}
|
||||
|
||||
// Fall back to sliding window truncation if needed
|
||||
// Debug: log fallback sliding-window check with current context window
|
||||
console.log(
|
||||
`[sliding-window] fallback check: contextWindow=${contextWindow}, prevContextTokens=${prevContextTokens}, allowedTokens=${allowedTokens}`,
|
||||
)
|
||||
if (prevContextTokens > allowedTokens) {
|
||||
const truncatedMessages = truncateConversation(messages, 0.5, taskId)
|
||||
return { messages: truncatedMessages, prevContextTokens, summary: "", cost, error }
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import {
|
|||
ORGANIZATION_ALLOW_ALL,
|
||||
DEFAULT_MODES,
|
||||
DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
|
||||
modelIdKeysByProvider,
|
||||
} from "@roo-code/types"
|
||||
import { TelemetryService } from "@roo-code/telemetry"
|
||||
import { CloudService, BridgeOrchestrator, getRooCodeApiUrl } from "@roo-code/cloud"
|
||||
|
|
@ -1301,26 +1302,58 @@ export class ClineProvider
|
|||
activate: boolean = true,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
// TODO: Do we need to be calling `activateProfile`? It's not
|
||||
// clear to me what the source of truth should be; in some cases
|
||||
// we rely on the `ContextProxy`'s data store and in other cases
|
||||
// we rely on the `ProviderSettingsManager`'s data store. It might
|
||||
// be simpler to unify these two.
|
||||
// Read previous state for change detection
|
||||
const prevState = await this.getState()
|
||||
const prev = (prevState?.apiConfiguration ?? {}) as ProviderSettings
|
||||
const next = providerSettings ?? ({} as ProviderSettings)
|
||||
|
||||
// Determine relevant keys for change detection
|
||||
const providerChanged = (prev.apiProvider || undefined) !== (next.apiProvider || undefined)
|
||||
|
||||
const providerName = next.apiProvider as ProviderName | undefined
|
||||
const modelKey = providerName
|
||||
? modelIdKeysByProvider[providerName as keyof typeof modelIdKeysByProvider]
|
||||
: undefined
|
||||
|
||||
const normalize = (v: unknown) => {
|
||||
if (v === null || v === undefined) return undefined
|
||||
const s = String(v).trim()
|
||||
return s.length ? s : undefined
|
||||
}
|
||||
|
||||
const modelChanged = modelKey
|
||||
? normalize((prev as any)[modelKey]) !== normalize((next as any)[modelKey])
|
||||
: false
|
||||
|
||||
// Base URL keys for router-compatible providers
|
||||
const baseUrlKey: keyof ProviderSettings | undefined = (() => {
|
||||
switch (providerName) {
|
||||
case "openrouter":
|
||||
return "openRouterBaseUrl"
|
||||
case "requesty":
|
||||
return "requestyBaseUrl"
|
||||
case "litellm":
|
||||
return "litellmBaseUrl"
|
||||
case "deepinfra":
|
||||
return "deepInfraBaseUrl"
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
|
||||
const baseUrlChanged = baseUrlKey
|
||||
? normalize((prev as any)[baseUrlKey]) !== normalize((next as any)[baseUrlKey])
|
||||
: false
|
||||
|
||||
const shouldReinit = providerChanged || modelChanged || baseUrlChanged
|
||||
|
||||
// Persist configuration first
|
||||
const id = await this.providerSettingsManager.saveConfig(name, providerSettings)
|
||||
|
||||
if (activate) {
|
||||
const { mode } = await this.getState()
|
||||
const { mode } = prevState
|
||||
|
||||
// These promises do the following:
|
||||
// 1. Adds or updates the list of provider profiles.
|
||||
// 2. Sets the current provider profile.
|
||||
// 3. Sets the current mode's provider profile.
|
||||
// 4. Copies the provider settings to the context.
|
||||
//
|
||||
// Note: 1, 2, and 4 can be done in one `ContextProxy` call:
|
||||
// this.contextProxy.setValues({ ...providerSettings, listApiConfigMeta: ..., currentApiConfigName: ... })
|
||||
// We should probably switch to that and verify that it works.
|
||||
// I left the original implementation in just to be safe.
|
||||
// Keep state in sync regardless of reinit
|
||||
await Promise.all([
|
||||
this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()),
|
||||
this.updateGlobalState("currentApiConfigName", name),
|
||||
|
|
@ -1328,12 +1361,16 @@ export class ClineProvider
|
|||
this.contextProxy.setProviderSettings(providerSettings),
|
||||
])
|
||||
|
||||
// Change the provider for the current task.
|
||||
// TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`).
|
||||
const task = this.getCurrentTask()
|
||||
|
||||
if (task) {
|
||||
task.api = buildApiHandler(providerSettings)
|
||||
// Only rebuild API handler if relevant fields changed
|
||||
if (shouldReinit) {
|
||||
console.log("[model-cache/save] Reinit: relevant fields changed")
|
||||
const task = this.getCurrentTask()
|
||||
if (task) {
|
||||
// Lightweight re-init (no forced fetch)
|
||||
task.api = buildApiHandler(providerSettings)
|
||||
}
|
||||
} else {
|
||||
console.log("[model-cache/save] No reinit: provider/model/baseUrl unchanged")
|
||||
}
|
||||
} else {
|
||||
await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig())
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ import { openMention } from "../mentions"
|
|||
import { getWorkspacePath } from "../../utils/path"
|
||||
import { Mode, defaultModeSlug } from "../../shared/modes"
|
||||
import { getModels, flushModels } from "../../api/providers/fetchers/modelCache"
|
||||
import { flushModelProviders } from "../../api/providers/fetchers/modelEndpointCache"
|
||||
import { GetModelsOptions } from "../../shared/api"
|
||||
import { generateSystemPrompt } from "./generateSystemPrompt"
|
||||
import { getCommand } from "../../utils/commands"
|
||||
|
|
@ -750,10 +751,111 @@ export const webviewMessageHandler = async (
|
|||
case "resetState":
|
||||
await provider.resetState()
|
||||
break
|
||||
case "flushRouterModels":
|
||||
const routerNameFlush: RouterName = toRouterName(message.text)
|
||||
await flushModels(routerNameFlush)
|
||||
case "flushRouterModels": {
|
||||
try {
|
||||
const { apiConfiguration, currentApiConfigName = "default" } = await provider.getState()
|
||||
const providerName = apiConfiguration?.apiProvider
|
||||
const router: RouterName = providerName ? toRouterName(providerName) : toRouterName(message.text)
|
||||
|
||||
// Determine selected modelId from provider profile
|
||||
let selectedModelId: string | undefined
|
||||
try {
|
||||
const { modelIdKeysByProvider } = await import("@roo-code/types")
|
||||
const key = providerName ? (modelIdKeysByProvider as any)[providerName] : undefined
|
||||
selectedModelId = key ? (apiConfiguration as any)[key] : (apiConfiguration as any)?.apiModelId
|
||||
} catch {
|
||||
selectedModelId = (apiConfiguration as any)?.apiModelId
|
||||
}
|
||||
|
||||
// Flush caches (memory + file)
|
||||
await flushModels(router)
|
||||
if (selectedModelId) {
|
||||
await flushModelProviders(router, selectedModelId)
|
||||
}
|
||||
console.log("[model-cache/refresh] Flushed memory+file cache for", router)
|
||||
|
||||
// Build options for refetch
|
||||
const buildOptions = (): GetModelsOptions => {
|
||||
switch (router) {
|
||||
case "requesty":
|
||||
return {
|
||||
provider: "requesty",
|
||||
apiKey: (apiConfiguration as any).requestyApiKey,
|
||||
baseUrl: (apiConfiguration as any).requestyBaseUrl,
|
||||
}
|
||||
case "glama":
|
||||
return { provider: "glama" }
|
||||
case "unbound":
|
||||
return { provider: "unbound", apiKey: (apiConfiguration as any).unboundApiKey }
|
||||
case "litellm":
|
||||
return {
|
||||
provider: "litellm",
|
||||
apiKey: (apiConfiguration as any).litellmApiKey,
|
||||
baseUrl: (apiConfiguration as any).litellmBaseUrl,
|
||||
}
|
||||
case "deepinfra":
|
||||
return {
|
||||
provider: "deepinfra",
|
||||
apiKey: (apiConfiguration as any).deepInfraApiKey,
|
||||
baseUrl: (apiConfiguration as any).deepInfraBaseUrl,
|
||||
}
|
||||
case "io-intelligence":
|
||||
return {
|
||||
provider: "io-intelligence",
|
||||
apiKey: (apiConfiguration as any).ioIntelligenceApiKey,
|
||||
}
|
||||
case "vercel-ai-gateway":
|
||||
return { provider: "vercel-ai-gateway" }
|
||||
case "openrouter":
|
||||
return { provider: "openrouter" }
|
||||
case "roo":
|
||||
return {
|
||||
provider: "roo",
|
||||
baseUrl: process.env.ROO_CODE_PROVIDER_URL ?? "https://api.roocode.com/proxy",
|
||||
apiKey: CloudService.hasInstance()
|
||||
? CloudService.instance.authService?.getSessionToken()
|
||||
: undefined,
|
||||
}
|
||||
case "chutes":
|
||||
return { provider: "chutes", apiKey: (apiConfiguration as any).chutesApiKey }
|
||||
case "ollama":
|
||||
return {
|
||||
provider: "ollama",
|
||||
baseUrl: (apiConfiguration as any).ollamaBaseUrl,
|
||||
apiKey: (apiConfiguration as any).ollamaApiKey,
|
||||
}
|
||||
case "lmstudio":
|
||||
return { provider: "lmstudio", baseUrl: (apiConfiguration as any).lmStudioBaseUrl }
|
||||
case "huggingface":
|
||||
return { provider: "huggingface" }
|
||||
default:
|
||||
return { provider: router }
|
||||
}
|
||||
}
|
||||
|
||||
// Refetch fresh models to warm caches
|
||||
const options = buildOptions()
|
||||
const models = await getModels(options)
|
||||
|
||||
// Persist resolvedModelInfo for selected model if available
|
||||
if (selectedModelId && models && models[selectedModelId]) {
|
||||
const info = models[selectedModelId] as any
|
||||
const updatedConfig = { ...apiConfiguration, resolvedModelInfo: info }
|
||||
await provider.upsertProviderProfile(currentApiConfigName || "default", updatedConfig, true)
|
||||
console.log("[model-cache/refresh] Persisted resolvedModelInfo for", router, selectedModelId)
|
||||
}
|
||||
|
||||
await provider.postMessageToWebview({ type: "flushRouterModelsResult", success: true })
|
||||
} catch (error) {
|
||||
console.warn("[model-cache/refresh] Refresh failed:", error)
|
||||
await provider.postMessageToWebview({
|
||||
type: "flushRouterModelsResult",
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
case "requestRouterModels":
|
||||
const { apiConfiguration } = await provider.getState()
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ try {
|
|||
console.warn("Failed to load environment variables:", e)
|
||||
}
|
||||
|
||||
import type { CloudUserInfo, AuthState } from "@roo-code/types"
|
||||
import type { CloudUserInfo, AuthState, ModelInfo } from "@roo-code/types"
|
||||
import { isDynamicProvider } from "@roo-code/types"
|
||||
import { CloudService, BridgeOrchestrator } from "@roo-code/cloud"
|
||||
import { TelemetryService, PostHogTelemetryClient } from "@roo-code/telemetry"
|
||||
|
||||
|
|
@ -31,6 +32,7 @@ import { MdmService } from "./services/mdm/MdmService"
|
|||
import { migrateSettings } from "./utils/migrateSettings"
|
||||
import { autoImportSettings } from "./utils/autoImportSettings"
|
||||
import { API } from "./extension/api"
|
||||
import { buildApiHandler } from "./api"
|
||||
|
||||
import {
|
||||
handleUri,
|
||||
|
|
@ -42,6 +44,60 @@ import {
|
|||
import { initializeI18n } from "./i18n"
|
||||
import { flushModels, getModels } from "./api/providers/fetchers/modelCache"
|
||||
|
||||
/**
|
||||
* Phase 3: activation-time self-healing population of resolvedModelInfo
|
||||
* Only activation wiring and persistence via existing API profile path.
|
||||
*/
|
||||
export async function ensureResolvedModelInfo(provider: ClineProvider): Promise<void> {
|
||||
try {
|
||||
const state = await provider.getState()
|
||||
const apiConfiguration = state.apiConfiguration
|
||||
const providerName = apiConfiguration?.apiProvider
|
||||
|
||||
// Process only dynamic providers
|
||||
if (!providerName || !isDynamicProvider(providerName)) {
|
||||
return
|
||||
}
|
||||
|
||||
// If resolvedModelInfo exists and is valid, skip
|
||||
const existing = apiConfiguration?.resolvedModelInfo as ModelInfo | undefined
|
||||
if (existing && typeof existing.contextWindow === "number" && typeof (existing as any).maxTokens === "number") {
|
||||
console.log("[model-cache] Using existing resolvedModelInfo for", providerName)
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[model-cache] Populating resolvedModelInfo for", providerName)
|
||||
|
||||
// Build handler and resolve model info (prefer fetchModel() if available)
|
||||
const handler = buildApiHandler(apiConfiguration)
|
||||
let info: ModelInfo | undefined
|
||||
|
||||
const maybeFetch = (handler as any)?.fetchModel
|
||||
if (typeof maybeFetch === "function") {
|
||||
const fetched = await maybeFetch.call(handler)
|
||||
if (fetched && typeof fetched === "object") {
|
||||
if ("info" in fetched && (fetched as any).info) {
|
||||
info = (fetched as any).info as ModelInfo
|
||||
} else if ("contextWindow" in fetched) {
|
||||
info = fetched as ModelInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!info) {
|
||||
info = handler.getModel().info
|
||||
}
|
||||
|
||||
if (info) {
|
||||
const profileName = state.currentApiConfigName || "default"
|
||||
const updatedConfig = { ...apiConfiguration, resolvedModelInfo: info }
|
||||
// Persist via same path as settings saves
|
||||
await provider.upsertProviderProfile(profileName, updatedConfig, true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[model-cache] Failed to populate resolvedModelInfo:", error)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Built using https://github.com/microsoft/vscode-webview-ui-toolkit
|
||||
*
|
||||
|
|
@ -254,6 +310,9 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||
)
|
||||
}
|
||||
|
||||
// Activation-time self-healing for resolvedModelInfo (non-blocking)
|
||||
void ensureResolvedModelInfo(provider)
|
||||
|
||||
registerCommands({ context, outputChannel, provider })
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ export interface ExtensionMessage {
|
|||
| "authenticatedUser"
|
||||
| "condenseTaskContextResponse"
|
||||
| "singleRouterModelFetchResponse"
|
||||
| "flushRouterModelsResult"
|
||||
| "indexingStatusUpdate"
|
||||
| "indexCleared"
|
||||
| "codebaseIndexConfig"
|
||||
|
|
|
|||
|
|
@ -232,12 +232,6 @@ const ApiOptions = ({
|
|||
vscode.postMessage({ type: "requestLmStudioModels" })
|
||||
} else if (selectedProvider === "vscode-lm") {
|
||||
vscode.postMessage({ type: "requestVsCodeLmModels" })
|
||||
} else if (
|
||||
selectedProvider === "litellm" ||
|
||||
selectedProvider === "deepinfra" ||
|
||||
selectedProvider === "roo"
|
||||
) {
|
||||
vscode.postMessage({ type: "requestRouterModels" })
|
||||
}
|
||||
},
|
||||
250,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ import { useEscapeKey } from "@src/hooks/useEscapeKey"
|
|||
|
||||
import { ModelInfoView } from "./ModelInfoView"
|
||||
import { ApiErrorMessage } from "./ApiErrorMessage"
|
||||
import { vscode } from "@src/utils/vscode"
|
||||
import type { ExtensionMessage } from "@roo/ExtensionMessage"
|
||||
|
||||
type ModelIdKey = keyof Pick<
|
||||
ProviderSettings,
|
||||
|
|
@ -71,6 +73,8 @@ export const ModelPicker = ({
|
|||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
|
||||
const [refreshStatus, setRefreshStatus] = useState<"idle" | "loading" | "success" | "error">("idle")
|
||||
const [refreshError, setRefreshError] = useState<string | undefined>()
|
||||
const isInitialized = useRef(false)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const selectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
|
@ -112,6 +116,12 @@ export const ModelPicker = ({
|
|||
setOpen(false)
|
||||
setApiConfigurationField(modelIdKey, modelId)
|
||||
|
||||
// Persist resolvedModelInfo immediately if available from cached models
|
||||
if (models && models[modelId]) {
|
||||
setApiConfigurationField("resolvedModelInfo", models[modelId], false)
|
||||
console.log("[model-cache/ui] persisted resolvedModelInfo from cached models")
|
||||
}
|
||||
|
||||
// Clear any existing timeout
|
||||
if (selectTimeoutRef.current) {
|
||||
clearTimeout(selectTimeoutRef.current)
|
||||
|
|
@ -120,7 +130,7 @@ export const ModelPicker = ({
|
|||
// Delay to ensure the popover is closed before setting the search value.
|
||||
selectTimeoutRef.current = setTimeout(() => setSearchValue(""), 100)
|
||||
},
|
||||
[modelIdKey, setApiConfigurationField],
|
||||
[modelIdKey, setApiConfigurationField, models],
|
||||
)
|
||||
|
||||
const onOpenChange = useCallback((open: boolean) => {
|
||||
|
|
@ -152,6 +162,26 @@ export const ModelPicker = ({
|
|||
isInitialized.current = true
|
||||
}, [modelIds, setApiConfigurationField, modelIdKey, selectedModelId, defaultModelId])
|
||||
|
||||
// Listen for refresh result messages
|
||||
useEffect(() => {
|
||||
const handler = (event: MessageEvent) => {
|
||||
const message = event.data as ExtensionMessage
|
||||
if (message.type === "flushRouterModelsResult") {
|
||||
if (message.success) {
|
||||
setRefreshStatus("success")
|
||||
setRefreshError(undefined)
|
||||
// Reset after brief success indication
|
||||
setTimeout(() => setRefreshStatus("idle"), 1500)
|
||||
} else {
|
||||
setRefreshStatus("error")
|
||||
setRefreshError(message.error || "Refresh failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener("message", handler)
|
||||
return () => window.removeEventListener("message", handler)
|
||||
}, [])
|
||||
|
||||
// Cleanup timeouts on unmount to prevent test flakiness
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
|
@ -170,7 +200,26 @@ export const ModelPicker = ({
|
|||
return (
|
||||
<>
|
||||
<div>
|
||||
<label className="block font-medium mb-1">{t("settings:modelPicker.label")}</label>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="block font-medium">{t("settings:modelPicker.label")}</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRefreshStatus("loading")
|
||||
setRefreshError(undefined)
|
||||
vscode.postMessage({ type: "flushRouterModels" })
|
||||
console.log("[model-cache/ui] refresh requested")
|
||||
}}
|
||||
disabled={refreshStatus === "loading"}
|
||||
className="h-7 px-2 text-xs rounded border border-vscode-button-border text-vscode-foreground bg-transparent hover:bg-vscode-list-hoverBackground disabled:opacity-60">
|
||||
{refreshStatus === "loading"
|
||||
? "Refreshing…"
|
||||
: refreshStatus === "success"
|
||||
? "Refreshed"
|
||||
: "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
{refreshStatus === "error" && refreshError && <ApiErrorMessage errorMessage={refreshError} />}
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import { describe, it, beforeEach, afterEach, expect } from "vitest"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { render, screen, fireEvent } from "@/utils/test-utils"
|
||||
import { act } from "react"
|
||||
|
||||
import { ModelPicker } from "../ModelPicker"
|
||||
import type { ModelInfo, ProviderSettings } from "@roo-code/types"
|
||||
|
||||
vi.mock("@src/context/ExtensionStateContext", () => ({
|
||||
useExtensionState: vi.fn(),
|
||||
}))
|
||||
|
||||
describe("ModelPicker - resolvedModelInfo persistence", () => {
|
||||
let queryClient: QueryClient
|
||||
let mockSetApiConfigurationField: ReturnType<typeof vi.fn>
|
||||
|
||||
const modelInfo: ModelInfo = {
|
||||
contextWindow: 32000,
|
||||
maxTokens: 16000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0.002,
|
||||
outputPrice: 0.006,
|
||||
}
|
||||
|
||||
const models: Record<string, ModelInfo> = {
|
||||
"openrouter/model": modelInfo,
|
||||
}
|
||||
|
||||
const apiConfiguration = {
|
||||
apiProvider: "openrouter",
|
||||
openRouterModelId: "openrouter/model",
|
||||
} as ProviderSettings
|
||||
|
||||
const renderComponent = () =>
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ModelPicker
|
||||
defaultModelId="openrouter/model"
|
||||
models={models}
|
||||
modelIdKey="openRouterModelId"
|
||||
serviceName="OpenRouter"
|
||||
serviceUrl="https://openrouter.ai"
|
||||
apiConfiguration={apiConfiguration}
|
||||
setApiConfigurationField={mockSetApiConfigurationField}
|
||||
organizationAllowList={{ allowAll: true, providers: {} }}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
queryClient = new QueryClient()
|
||||
mockSetApiConfigurationField = vi.fn()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear()
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("persists resolvedModelInfo with cached metadata when a model is selected", async () => {
|
||||
await act(async () => {
|
||||
renderComponent()
|
||||
})
|
||||
|
||||
// Clear initialization calls so we only assert on user interaction
|
||||
mockSetApiConfigurationField.mockClear()
|
||||
|
||||
const trigger = screen.getByTestId("model-picker-button")
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// Allow popover animations to settle
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
const option = screen.getByTestId("model-option-openrouter/model")
|
||||
fireEvent.click(option)
|
||||
|
||||
// Allow onSelect timeout handlers to complete
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
expect(mockSetApiConfigurationField).toHaveBeenCalledWith("openRouterModelId", "openrouter/model")
|
||||
expect(mockSetApiConfigurationField).toHaveBeenCalledWith("resolvedModelInfo", modelInfo, false)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import { describe, it, beforeEach, afterEach, expect } from "vitest"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { render, screen, fireEvent } from "@/utils/test-utils"
|
||||
import { act } from "react"
|
||||
|
||||
import { ModelPicker } from "../ModelPicker"
|
||||
import type { ModelInfo, ProviderSettings } from "@roo-code/types"
|
||||
|
||||
vi.mock("@src/context/ExtensionStateContext", () => ({
|
||||
useExtensionState: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@src/utils/vscode", () => ({
|
||||
vscode: {
|
||||
postMessage: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { vscode } from "@src/utils/vscode"
|
||||
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
|
||||
describe("ModelPicker refresh behavior", () => {
|
||||
let queryClient: QueryClient
|
||||
let mockSetApiConfigurationField: ReturnType<typeof vi.fn>
|
||||
|
||||
const modelInfo: ModelInfo = {
|
||||
contextWindow: 32000,
|
||||
maxTokens: 16000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0.002,
|
||||
outputPrice: 0.006,
|
||||
}
|
||||
|
||||
const models: Record<string, ModelInfo> = {
|
||||
"openrouter/model": modelInfo,
|
||||
}
|
||||
|
||||
const apiConfiguration = {
|
||||
apiProvider: "openrouter",
|
||||
openRouterModelId: "openrouter/model",
|
||||
} as ProviderSettings
|
||||
|
||||
const renderComponent = () =>
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ModelPicker
|
||||
defaultModelId="openrouter/model"
|
||||
models={models}
|
||||
modelIdKey="openRouterModelId"
|
||||
serviceName="OpenRouter"
|
||||
serviceUrl="https://openrouter.ai"
|
||||
apiConfiguration={apiConfiguration}
|
||||
setApiConfigurationField={mockSetApiConfigurationField}
|
||||
organizationAllowList={{ allowAll: true, providers: {} }}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient()
|
||||
mockSetApiConfigurationField = vi.fn()
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear()
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("posts flushRouterModels message and shows success state on completion", async () => {
|
||||
act(() => {
|
||||
renderComponent()
|
||||
})
|
||||
|
||||
const refreshButton = screen.getByRole("button", { name: "Refresh" })
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(refreshButton)
|
||||
})
|
||||
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "flushRouterModels" })
|
||||
expect(refreshButton).toHaveTextContent("Refreshing…")
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
type: "flushRouterModelsResult",
|
||||
success: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
expect(refreshButton).toHaveTextContent("Refreshed")
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1500)
|
||||
})
|
||||
|
||||
expect(refreshButton).toHaveTextContent("Refresh")
|
||||
expect(screen.queryByTestId("api-error-message")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("surfaces error message when refresh fails", async () => {
|
||||
act(() => {
|
||||
renderComponent()
|
||||
})
|
||||
|
||||
const refreshButton = screen.getByRole("button", { name: "Refresh" })
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(refreshButton)
|
||||
})
|
||||
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "flushRouterModels" })
|
||||
|
||||
const errorMessage = "something went wrong"
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
type: "flushRouterModelsResult",
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
expect(refreshButton).toHaveTextContent("Refresh")
|
||||
expect(screen.getByTestId("api-error-message")).toHaveTextContent(errorMessage)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
import { renderHook, act } from "@testing-library/react"
|
||||
import type { ReactNode } from "react"
|
||||
import type { ProviderSettings } from "@roo-code/types"
|
||||
|
||||
const createWrapper =
|
||||
() =>
|
||||
({ children }: { children: ReactNode }) => <>{children}</>
|
||||
|
||||
const setupUseRouterModels = async () => {
|
||||
vi.resetModules()
|
||||
|
||||
const useQueryMock = vi.fn().mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
|
||||
const postMessageMock = vi.fn()
|
||||
|
||||
vi.doMock("@tanstack/react-query", () => ({
|
||||
useQuery: useQueryMock,
|
||||
}))
|
||||
|
||||
vi.doMock("@src/utils/vscode", () => ({
|
||||
vscode: { postMessage: postMessageMock },
|
||||
}))
|
||||
|
||||
const mod = await import("../useRouterModels")
|
||||
|
||||
return {
|
||||
useRouterModels: mod.useRouterModels,
|
||||
useQueryMock,
|
||||
postMessageMock,
|
||||
}
|
||||
}
|
||||
|
||||
const setupUseSelectedModel = async () => {
|
||||
vi.resetModules()
|
||||
|
||||
const useRouterModelsMock = vi.fn().mockReturnValue({
|
||||
data: {
|
||||
openrouter: {},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
})
|
||||
|
||||
vi.doMock("../useRouterModels", () => ({
|
||||
useRouterModels: useRouterModelsMock,
|
||||
}))
|
||||
|
||||
vi.doMock("../useOpenRouterModelProviders", () => ({
|
||||
useOpenRouterModelProviders: vi.fn().mockReturnValue({
|
||||
data: {},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock("../useLmStudioModels", () => ({
|
||||
useLmStudioModels: vi.fn().mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.doMock("../useOllamaModels", () => ({
|
||||
useOllamaModels: vi.fn().mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mod = await import("../useSelectedModel")
|
||||
|
||||
return {
|
||||
useSelectedModel: mod.useSelectedModel,
|
||||
useRouterModelsMock,
|
||||
}
|
||||
}
|
||||
|
||||
describe("useRouterModels", () => {
|
||||
it("disables auto-fetch by default so callers must refetch explicitly", async () => {
|
||||
const { useRouterModels, useQueryMock } = await setupUseRouterModels()
|
||||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
|
||||
renderHook(() => useRouterModels(), { wrapper: createWrapper() })
|
||||
|
||||
expect(useQueryMock).toHaveBeenCalledTimes(1)
|
||||
const call = useQueryMock.mock.calls[0][0] as { enabled: boolean }
|
||||
expect(call.enabled).toBe(false)
|
||||
expect(consoleSpy).toHaveBeenCalledWith("[model-cache/ui] auto-fetch disabled; relying on explicit refresh")
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("uses provider-scoped query keys and only fetches when queryFn is invoked", async () => {
|
||||
vi.useFakeTimers()
|
||||
const { useRouterModels, useQueryMock, postMessageMock } = await setupUseRouterModels()
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useRouterModels({
|
||||
provider: "roo",
|
||||
enabled: true,
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(useQueryMock).toHaveBeenCalledTimes(1)
|
||||
const options = useQueryMock.mock.calls[0][0] as {
|
||||
queryKey: [string, string]
|
||||
enabled: boolean
|
||||
queryFn: () => Promise<Record<string, unknown>>
|
||||
}
|
||||
|
||||
expect(options.enabled).toBe(true)
|
||||
expect(options.queryKey).toEqual(["routerModels", "roo"])
|
||||
|
||||
const pending = options.queryFn()
|
||||
expect(postMessageMock).toHaveBeenCalledWith({ type: "requestRouterModels", values: { provider: "roo" } })
|
||||
|
||||
const response = { roo: { "roo/model": {} } }
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
type: "routerModels",
|
||||
values: { provider: "roo" },
|
||||
routerModels: response,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await expect(pending).resolves.toBe(response)
|
||||
})
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe("useSelectedModel", () => {
|
||||
it("keeps router model queries disabled on mount to avoid implicit fetches", async () => {
|
||||
const { useSelectedModel, useRouterModelsMock } = await setupUseSelectedModel()
|
||||
|
||||
const apiConfiguration = {
|
||||
apiProvider: "openrouter",
|
||||
openRouterModelId: "openrouter/model",
|
||||
} as ProviderSettings
|
||||
|
||||
renderHook(() => useSelectedModel(apiConfiguration), { wrapper: createWrapper() })
|
||||
|
||||
expect(useRouterModelsMock).toHaveBeenCalledWith({ enabled: false })
|
||||
})
|
||||
})
|
||||
|
|
@ -5,6 +5,8 @@ import { ExtensionMessage } from "@roo/ExtensionMessage"
|
|||
|
||||
import { vscode } from "@src/utils/vscode"
|
||||
|
||||
let warnedAutoFetchDisabled = false
|
||||
|
||||
type UseRouterModelsOptions = {
|
||||
provider?: string // single provider filter (e.g. "roo")
|
||||
enabled?: boolean // gate fetching entirely
|
||||
|
|
@ -54,9 +56,17 @@ const getRouterModels = async (provider?: string) =>
|
|||
|
||||
export const useRouterModels = (opts: UseRouterModelsOptions = {}) => {
|
||||
const provider = opts.provider || undefined
|
||||
const enabled = Boolean(opts.enabled)
|
||||
|
||||
// Trace once when auto-fetch is disabled (Phase 5.1)
|
||||
if (!enabled && !warnedAutoFetchDisabled) {
|
||||
console.log("[model-cache/ui] auto-fetch disabled; relying on explicit refresh")
|
||||
warnedAutoFetchDisabled = true
|
||||
}
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["routerModels", provider || "all"],
|
||||
queryFn: () => getRouterModels(provider),
|
||||
enabled: opts.enabled !== false,
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,19 +56,15 @@ export const useSelectedModel = (apiConfiguration?: ProviderSettings) => {
|
|||
const lmStudioModelId = provider === "lmstudio" ? apiConfiguration?.lmStudioModelId : undefined
|
||||
const ollamaModelId = provider === "ollama" ? apiConfiguration?.ollamaModelId : undefined
|
||||
|
||||
// Only fetch router models for dynamic providers
|
||||
const shouldFetchRouterModels = isDynamicProvider(provider)
|
||||
const routerModels = useRouterModels({
|
||||
provider: shouldFetchRouterModels ? provider : undefined,
|
||||
enabled: shouldFetchRouterModels,
|
||||
})
|
||||
// Do not auto-fetch router models; rely on explicit refresh (Phase 5.1)
|
||||
const routerModels = useRouterModels({ enabled: false })
|
||||
|
||||
const openRouterModelProviders = useOpenRouterModelProviders(openRouterModelId)
|
||||
const lmStudioModels = useLmStudioModels(lmStudioModelId)
|
||||
const ollamaModels = useOllamaModels(ollamaModelId)
|
||||
|
||||
// Compute readiness only for the data actually needed for the selected provider
|
||||
const needRouterModels = shouldFetchRouterModels
|
||||
// Compute readiness only for data actually needed; router models use cached data without fetch
|
||||
const needRouterModels = false
|
||||
const needOpenRouterProviders = provider === "openrouter"
|
||||
const needLmStudio = typeof lmStudioModelId !== "undefined"
|
||||
const needOllama = typeof ollamaModelId !== "undefined"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue