mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
fix: copy model-level capabilities to OpenRouter endpoint models (#9483)
This commit is contained in:
parent
fa764ba7f1
commit
f6d3fdfff6
3 changed files with 344 additions and 8 deletions
166
src/api/providers/fetchers/__tests__/modelEndpointCache.spec.ts
Normal file
166
src/api/providers/fetchers/__tests__/modelEndpointCache.spec.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
// npx vitest run api/providers/fetchers/__tests__/modelEndpointCache.spec.ts
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from "vitest"
|
||||
import { getModelEndpoints } from "../modelEndpointCache"
|
||||
import * as modelCache from "../modelCache"
|
||||
import * as openrouter from "../openrouter"
|
||||
|
||||
vi.mock("../modelCache")
|
||||
vi.mock("../openrouter")
|
||||
|
||||
describe("modelEndpointCache", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("getModelEndpoints", () => {
|
||||
it("should copy model-level capabilities from parent model to endpoints", async () => {
|
||||
// Mock the parent model data with native tools support
|
||||
const mockParentModels = {
|
||||
"anthropic/claude-sonnet-4": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsNativeTools: true, // Parent supports native tools
|
||||
supportsReasoningEffort: true,
|
||||
supportedParameters: ["max_tokens", "temperature", "reasoning"] as any,
|
||||
inputPrice: 3,
|
||||
outputPrice: 15,
|
||||
},
|
||||
}
|
||||
|
||||
// Mock endpoint data WITHOUT capabilities (as returned by API)
|
||||
const mockEndpoints = {
|
||||
anthropic: {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 3,
|
||||
outputPrice: 15,
|
||||
// Note: No supportsNativeTools, supportsReasoningEffort, or supportedParameters
|
||||
},
|
||||
"amazon-bedrock": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 3,
|
||||
outputPrice: 15,
|
||||
},
|
||||
}
|
||||
|
||||
vi.spyOn(modelCache, "getModels").mockResolvedValue(mockParentModels as any)
|
||||
vi.spyOn(openrouter, "getOpenRouterModelEndpoints").mockResolvedValue(mockEndpoints as any)
|
||||
|
||||
const result = await getModelEndpoints({
|
||||
router: "openrouter",
|
||||
modelId: "anthropic/claude-sonnet-4",
|
||||
endpoint: "anthropic",
|
||||
})
|
||||
|
||||
// Verify capabilities were copied from parent to ALL endpoints
|
||||
expect(result.anthropic.supportsNativeTools).toBe(true)
|
||||
expect(result.anthropic.supportsReasoningEffort).toBe(true)
|
||||
expect(result.anthropic.supportedParameters).toEqual(["max_tokens", "temperature", "reasoning"])
|
||||
|
||||
expect(result["amazon-bedrock"].supportsNativeTools).toBe(true)
|
||||
expect(result["amazon-bedrock"].supportsReasoningEffort).toBe(true)
|
||||
expect(result["amazon-bedrock"].supportedParameters).toEqual(["max_tokens", "temperature", "reasoning"])
|
||||
})
|
||||
|
||||
it("should create independent array copies to avoid shared references", async () => {
|
||||
const mockParentModels = {
|
||||
"test/model": {
|
||||
maxTokens: 1000,
|
||||
contextWindow: 10000,
|
||||
supportsPromptCache: false,
|
||||
supportsNativeTools: true,
|
||||
supportedParameters: ["max_tokens", "temperature"] as any,
|
||||
},
|
||||
}
|
||||
|
||||
const mockEndpoints = {
|
||||
"endpoint-1": {
|
||||
maxTokens: 1000,
|
||||
contextWindow: 10000,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
"endpoint-2": {
|
||||
maxTokens: 1000,
|
||||
contextWindow: 10000,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
}
|
||||
|
||||
vi.spyOn(modelCache, "getModels").mockResolvedValue(mockParentModels as any)
|
||||
vi.spyOn(openrouter, "getOpenRouterModelEndpoints").mockResolvedValue(mockEndpoints as any)
|
||||
|
||||
const result = await getModelEndpoints({
|
||||
router: "openrouter",
|
||||
modelId: "test/model",
|
||||
endpoint: "endpoint-1",
|
||||
})
|
||||
|
||||
// Modify one endpoint's array
|
||||
result["endpoint-1"].supportedParameters?.push("reasoning" as any)
|
||||
|
||||
// Verify the other endpoint's array was NOT affected (independent copy)
|
||||
expect(result["endpoint-1"].supportedParameters).toHaveLength(3)
|
||||
expect(result["endpoint-2"].supportedParameters).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should handle missing parent model gracefully", async () => {
|
||||
const mockParentModels = {}
|
||||
const mockEndpoints = {
|
||||
anthropic: {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
},
|
||||
}
|
||||
|
||||
vi.spyOn(modelCache, "getModels").mockResolvedValue(mockParentModels as any)
|
||||
vi.spyOn(openrouter, "getOpenRouterModelEndpoints").mockResolvedValue(mockEndpoints as any)
|
||||
|
||||
const result = await getModelEndpoints({
|
||||
router: "openrouter",
|
||||
modelId: "missing/model",
|
||||
endpoint: "anthropic",
|
||||
})
|
||||
|
||||
// Should not crash, but capabilities will be undefined
|
||||
expect(result.anthropic).toBeDefined()
|
||||
expect(result.anthropic.supportsNativeTools).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should return empty object for non-openrouter providers", async () => {
|
||||
const result = await getModelEndpoints({
|
||||
router: "vercel-ai-gateway",
|
||||
modelId: "claude-sonnet-4",
|
||||
endpoint: "default",
|
||||
})
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it("should return empty object when modelId or endpoint is missing", async () => {
|
||||
const result1 = await getModelEndpoints({
|
||||
router: "openrouter",
|
||||
modelId: undefined,
|
||||
endpoint: "anthropic",
|
||||
})
|
||||
|
||||
const result2 = await getModelEndpoints({
|
||||
router: "openrouter",
|
||||
modelId: "anthropic/claude-sonnet-4",
|
||||
endpoint: undefined,
|
||||
})
|
||||
|
||||
expect(result1).toEqual({})
|
||||
expect(result2).toEqual({})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -82,9 +82,78 @@ describe("OpenRouter API", () => {
|
|||
|
||||
describe("getOpenRouterModelEndpoints", () => {
|
||||
it("fetches model endpoints and validates schema", async () => {
|
||||
const { nockDone } = await nockBack("openrouter-model-endpoints.json")
|
||||
const mockEndpointsResponse = {
|
||||
data: {
|
||||
data: {
|
||||
id: "google/gemini-2.5-pro-preview",
|
||||
name: "Gemini 2.5 Pro Preview",
|
||||
architecture: {
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["text"],
|
||||
},
|
||||
endpoints: [
|
||||
{
|
||||
provider_name: "Google Vertex",
|
||||
tag: "google-vertex",
|
||||
context_length: 1048576,
|
||||
max_completion_tokens: 65535,
|
||||
pricing: {
|
||||
prompt: "0.00000125",
|
||||
completion: "0.00001",
|
||||
input_cache_write: "0.000001625",
|
||||
input_cache_read: "0.00000031",
|
||||
},
|
||||
},
|
||||
{
|
||||
provider_name: "Google AI Studio",
|
||||
tag: "google-ai-studio",
|
||||
context_length: 1048576,
|
||||
max_completion_tokens: 65536,
|
||||
pricing: {
|
||||
prompt: "0.00000125",
|
||||
completion: "0.00001",
|
||||
input_cache_write: "0.000001625",
|
||||
input_cache_read: "0.00000031",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Mock cached parent model data
|
||||
const mockCachedModels = {
|
||||
"google/gemini-2.5-pro-preview": {
|
||||
maxTokens: 65536,
|
||||
contextWindow: 1048576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoningBudget: true,
|
||||
inputPrice: 1.25,
|
||||
outputPrice: 10,
|
||||
cacheWritesPrice: 1.625,
|
||||
cacheReadsPrice: 0.31,
|
||||
supportsReasoningEffort: true,
|
||||
supportsNativeTools: false, // Gemini doesn't support native tools via "tools" parameter
|
||||
supportedParameters: ["max_tokens", "temperature", "reasoning"],
|
||||
},
|
||||
} as Record<string, any>
|
||||
|
||||
const axios = await import("axios")
|
||||
const getSpy = vi.spyOn(axios.default, "get").mockResolvedValue(mockEndpointsResponse)
|
||||
|
||||
const endpoints = await getOpenRouterModelEndpoints("google/gemini-2.5-pro-preview")
|
||||
|
||||
// Simulate what modelEndpointCache does - copy capabilities from parent
|
||||
const parentModel = mockCachedModels["google/gemini-2.5-pro-preview"]
|
||||
if (parentModel) {
|
||||
for (const key of Object.keys(endpoints)) {
|
||||
endpoints[key].supportsNativeTools = parentModel.supportsNativeTools
|
||||
endpoints[key].supportsReasoningEffort = parentModel.supportsReasoningEffort
|
||||
endpoints[key].supportedParameters = parentModel.supportedParameters
|
||||
}
|
||||
}
|
||||
|
||||
expect(endpoints).toEqual({
|
||||
"google-vertex": {
|
||||
maxTokens: 65535,
|
||||
|
|
@ -97,9 +166,9 @@ describe("OpenRouter API", () => {
|
|||
cacheWritesPrice: 1.625,
|
||||
cacheReadsPrice: 0.31,
|
||||
description: undefined,
|
||||
supportsReasoningEffort: undefined,
|
||||
supportsNativeTools: undefined,
|
||||
supportedParameters: undefined,
|
||||
supportsReasoningEffort: true,
|
||||
supportsNativeTools: false, // Copied from parent model
|
||||
supportedParameters: ["max_tokens", "temperature", "reasoning"],
|
||||
},
|
||||
"google-ai-studio": {
|
||||
maxTokens: 65536,
|
||||
|
|
@ -112,13 +181,94 @@ describe("OpenRouter API", () => {
|
|||
cacheWritesPrice: 1.625,
|
||||
cacheReadsPrice: 0.31,
|
||||
description: undefined,
|
||||
supportsReasoningEffort: undefined,
|
||||
supportsNativeTools: undefined,
|
||||
supportedParameters: undefined,
|
||||
supportsReasoningEffort: true,
|
||||
supportsNativeTools: false, // Copied from parent model
|
||||
supportedParameters: ["max_tokens", "temperature", "reasoning"],
|
||||
},
|
||||
})
|
||||
|
||||
nockDone()
|
||||
getSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("copies model-level capabilities from parent model to endpoint models", async () => {
|
||||
const mockEndpointsResponse = {
|
||||
data: {
|
||||
data: {
|
||||
id: "anthropic/claude-sonnet-4",
|
||||
name: "Claude Sonnet 4",
|
||||
description: "Latest Claude model",
|
||||
architecture: {
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["text"],
|
||||
},
|
||||
endpoints: [
|
||||
{
|
||||
provider_name: "Anthropic",
|
||||
name: "Claude Sonnet 4",
|
||||
context_length: 200000,
|
||||
max_completion_tokens: 8192,
|
||||
pricing: {
|
||||
prompt: "0.000003",
|
||||
completion: "0.000015",
|
||||
input_cache_write: "0.00000375",
|
||||
input_cache_read: "0.0000003",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Mock cached parent model with native tools support
|
||||
const mockCachedModels = {
|
||||
"anthropic/claude-sonnet-4": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoningBudget: true,
|
||||
inputPrice: 3,
|
||||
outputPrice: 15,
|
||||
cacheWritesPrice: 3.75,
|
||||
cacheReadsPrice: 0.3,
|
||||
supportsReasoningEffort: true,
|
||||
supportsNativeTools: true, // Anthropic supports native tools
|
||||
supportedParameters: ["max_tokens", "temperature", "reasoning"],
|
||||
},
|
||||
} as Record<string, any>
|
||||
|
||||
const axios = await import("axios")
|
||||
const getSpy = vi.spyOn(axios.default, "get").mockResolvedValue(mockEndpointsResponse)
|
||||
|
||||
const endpoints = await getOpenRouterModelEndpoints("anthropic/claude-sonnet-4")
|
||||
|
||||
// Simulate what modelEndpointCache does - copy capabilities from parent
|
||||
const parentModel = mockCachedModels["anthropic/claude-sonnet-4"]
|
||||
if (parentModel) {
|
||||
for (const key of Object.keys(endpoints)) {
|
||||
endpoints[key].supportsNativeTools = parentModel.supportsNativeTools
|
||||
endpoints[key].supportsReasoningEffort = parentModel.supportsReasoningEffort
|
||||
endpoints[key].supportedParameters = parentModel.supportedParameters
|
||||
}
|
||||
}
|
||||
|
||||
expect(endpoints["Anthropic"]).toEqual({
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 3,
|
||||
outputPrice: 15,
|
||||
cacheWritesPrice: 3.75,
|
||||
cacheReadsPrice: 0.3,
|
||||
description: undefined,
|
||||
supportsReasoningBudget: true,
|
||||
supportsReasoningEffort: true,
|
||||
supportsNativeTools: true, // Copied from parent model
|
||||
supportedParameters: ["max_tokens", "temperature", "reasoning"],
|
||||
})
|
||||
|
||||
getSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { RouterName, ModelRecord } from "../../../shared/api"
|
|||
import { fileExistsAtPath } from "../../../utils/fs"
|
||||
|
||||
import { getOpenRouterModelEndpoints } from "./openrouter"
|
||||
import { getModels } from "./modelCache"
|
||||
|
||||
const memoryCache = new NodeCache({ stdTTL: 5 * 60, checkperiod: 5 * 60 })
|
||||
|
||||
|
|
@ -55,6 +56,25 @@ export const getModelEndpoints = async ({
|
|||
|
||||
modelProviders = await getOpenRouterModelEndpoints(modelId)
|
||||
|
||||
// Copy model-level capabilities from the parent model to each endpoint
|
||||
// These are capabilities that don't vary by provider (tools, reasoning, etc.)
|
||||
if (Object.keys(modelProviders).length > 0) {
|
||||
const parentModels = await getModels({ provider: "openrouter" })
|
||||
const parentModel = parentModels[modelId]
|
||||
|
||||
if (parentModel) {
|
||||
// Copy model-level capabilities to all endpoints
|
||||
// Clone arrays to avoid shared mutable references
|
||||
for (const endpointKey of Object.keys(modelProviders)) {
|
||||
modelProviders[endpointKey].supportsNativeTools = parentModel.supportsNativeTools
|
||||
modelProviders[endpointKey].supportsReasoningEffort = parentModel.supportsReasoningEffort
|
||||
modelProviders[endpointKey].supportedParameters = parentModel.supportedParameters
|
||||
? [...parentModel.supportedParameters]
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(modelProviders).length > 0) {
|
||||
// console.log(`[getModelProviders] API fetch for ${key} -> ${Object.keys(modelProviders).length}`)
|
||||
memoryCache.set(key, modelProviders)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue