diff --git a/apps/cli/src/lib/utils/context-window.ts b/apps/cli/src/lib/utils/context-window.ts index b78ab0e5b5..24ab51fd1d 100644 --- a/apps/cli/src/lib/utils/context-window.ts +++ b/apps/cli/src/lib/utils/context-window.ts @@ -66,6 +66,7 @@ function getModelIdForProvider(config: ProviderSettings): string | undefined { case providerIdentifiers.deepseek: case providerIdentifiers.fireworks: case providerIdentifiers.friendli: + case providerIdentifiers.neuronpool: case providerIdentifiers.gemini: case providerIdentifiers.geminiCli: case providerIdentifiers.mistral: diff --git a/packages/types/src/__tests__/provider-identifiers.test.ts b/packages/types/src/__tests__/provider-identifiers.test.ts index 29e55b31cf..82d25b45c8 100644 --- a/packages/types/src/__tests__/provider-identifiers.test.ts +++ b/packages/types/src/__tests__/provider-identifiers.test.ts @@ -43,6 +43,7 @@ const expectedProviderIdentifiers = [ "baseten", "fireworks", "friendli", + "neuronpool", "gemini", "gemini-cli", "mistral", diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 95f246dbe7..61a639584a 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -335,6 +335,7 @@ export const SECRET_STATE_KEYS = [ "zaiApiKey", "fireworksApiKey", "friendliApiKey", + "neuronpoolApiKey", "vercelAiGatewayApiKey", "opencodeGoApiKey", "kenariApiKey", diff --git a/packages/types/src/provider-identifiers.ts b/packages/types/src/provider-identifiers.ts index fdf507bb6b..c501c08cfb 100644 --- a/packages/types/src/provider-identifiers.ts +++ b/packages/types/src/provider-identifiers.ts @@ -25,6 +25,7 @@ export const providerIdentifiers = { baseten: "baseten", fireworks: "fireworks", friendli: "friendli", + neuronpool: "neuronpool", gemini: "gemini", geminiCli: "gemini-cli", mistral: "mistral", diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 0b898f1b66..3fd75657ab 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -30,6 +30,7 @@ import { deepSeekModels, fireworksModels, friendliModels, + neuronpoolModels, geminiModels, mistralModels, moonshotModels, @@ -430,6 +431,11 @@ export const MODELS_BY_PROVIDER: Record< label: "Friendli", models: Object.keys(friendliModels), }, + [providerIdentifiers.neuronpool]: { + id: providerIdentifiers.neuronpool, + label: "NeuronPool", + models: Object.keys(neuronpoolModels), + }, [providerIdentifiers.gemini]: { id: providerIdentifiers.gemini, label: "Google Gemini", diff --git a/packages/types/src/provider-settings/index.ts b/packages/types/src/provider-settings/index.ts index 3fe8790af1..26ba1e6d5d 100644 --- a/packages/types/src/provider-settings/index.ts +++ b/packages/types/src/provider-settings/index.ts @@ -26,6 +26,7 @@ import { sambaNovaProviderDefinition } from "./sambanova.js" import { zaiProviderDefinition } from "./zai.js" import { fireworksProviderDefinition } from "./fireworks.js" import { friendliProviderDefinition } from "./friendli.js" +import { neuronpoolProviderDefinition } from "./neuronpool.js" import { qwenCodeProviderDefinition } from "./qwen-code.js" import { vercelAiGatewayProviderDefinition } from "./vercel-ai-gateway.js" import { opencodeGoProviderDefinition } from "./opencode-go.js" @@ -77,6 +78,7 @@ export const providerDefinitionList = [ zaiProviderDefinition, fireworksProviderDefinition, friendliProviderDefinition, + neuronpoolProviderDefinition, qwenCodeProviderDefinition, vercelAiGatewayProviderDefinition, opencodeGoProviderDefinition, diff --git a/packages/types/src/provider-settings/neuronpool.ts b/packages/types/src/provider-settings/neuronpool.ts new file mode 100644 index 0000000000..431f409218 --- /dev/null +++ b/packages/types/src/provider-settings/neuronpool.ts @@ -0,0 +1,20 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { + API_MODEL_ID_FIELD, + apiModelIdProviderModelShape, + createModelIdAccessor, + createProviderDefinition, +} from "./common.js" + +export const neuronpoolProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.neuronpool, + modelIdKey: API_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(API_MODEL_ID_FIELD), + schema: { + ...apiModelIdProviderModelShape, + neuronpoolApiKey: z.string().optional(), + neuronpoolBaseUrl: z.string().optional(), + }, +}) diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index b26476eaed..6d5c2ba983 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -31,6 +31,7 @@ export * from "./zai.js" export * from "./minimax.js" export * from "./mimo.js" export * from "./zoo-gateway.js" +export * from "./neuronpool.js" import { anthropicDefaultModelId } from "./anthropic.js" import { basetenDefaultModelId } from "./baseten.js" @@ -61,6 +62,7 @@ import { internationalZAiDefaultModelId, mainlandZAiDefaultModelId } from "./zai import { minimaxDefaultModelId } from "./minimax.js" import { mimoDefaultModelId } from "./mimo.js" import { zooGatewayDefaultModelId } from "./zoo-gateway.js" +import { neuronpoolDefaultModelId } from "./neuronpool.js" // Import the ProviderName type from provider-settings to avoid duplication import type { ProviderName } from "../provider-settings.js" @@ -123,6 +125,8 @@ export function getProviderDefaultModelId( return fireworksDefaultModelId case providerIdentifiers.friendli: return friendliDefaultModelId + case providerIdentifiers.neuronpool: + return neuronpoolDefaultModelId case providerIdentifiers.qwenCode: return qwenCodeDefaultModelId case providerIdentifiers.poe: diff --git a/packages/types/src/providers/neuronpool.ts b/packages/types/src/providers/neuronpool.ts new file mode 100644 index 0000000000..360b9baff1 --- /dev/null +++ b/packages/types/src/providers/neuronpool.ts @@ -0,0 +1,80 @@ +import type { ModelInfo } from "../model.js" + +// NeuronPool — OpenAI-compatible social compute / public marketplace. +// Live catalog: GET {baseURL}/models (default https://neuronpool.damnknee.workers.dev/v1) +export type NeuronPoolModelId = + | "gpt-oss-20b" + | "llama-3.2-1b-instruct" + | "llama-3.1-8b-instruct" + | "qwen2.5-7b-instruct" + | "gemma-3-12b-it" + | "qwen3-30b-a3b" + | "neuronpool-tiny-chat" + +export const neuronpoolDefaultModelId: NeuronPoolModelId = "gpt-oss-20b" + +const chatDefaults = { + supportsImages: false, + supportsPromptCache: false, + maxTokens: 4096, +} as const + +export const neuronpoolDefaultModelInfo: ModelInfo = { + ...chatDefaults, + contextWindow: 131_072, + inputPrice: 0.015, + outputPrice: 0.07, + description: "gpt-oss-20b via NeuronPool (OpenAI-compatible chat + tools).", +} + +export const neuronpoolModels = { + "gpt-oss-20b": { + ...chatDefaults, + contextWindow: 131_072, + inputPrice: 0.015, + outputPrice: 0.07, + description: "gpt-oss-20b via NeuronPool.", + }, + "llama-3.2-1b-instruct": { + ...chatDefaults, + contextWindow: 131_072, + inputPrice: 0.005, + outputPrice: 0.01, + description: "Llama 3.2 1B Instruct via NeuronPool.", + }, + "llama-3.1-8b-instruct": { + ...chatDefaults, + contextWindow: 131_072, + inputPrice: 0.02, + outputPrice: 0.03, + description: "Llama 3.1 8B Instruct via NeuronPool.", + }, + "qwen2.5-7b-instruct": { + ...chatDefaults, + contextWindow: 32_768, + inputPrice: 0.02, + outputPrice: 0.03, + description: "Qwen2.5 7B Instruct via NeuronPool.", + }, + "gemma-3-12b-it": { + ...chatDefaults, + contextWindow: 131_072, + inputPrice: 0.04, + outputPrice: 0.06, + description: "Gemma 3 12B IT via NeuronPool.", + }, + "qwen3-30b-a3b": { + ...chatDefaults, + contextWindow: 32_768, + inputPrice: 0.06, + outputPrice: 0.09, + description: "Qwen3 30B A3B via NeuronPool.", + }, + "neuronpool-tiny-chat": { + ...chatDefaults, + contextWindow: 4096, + inputPrice: 0.001, + outputPrice: 0.002, + description: "In-house tiny chat model for smoke tests.", + }, +} as const satisfies Record diff --git a/src/api/__tests__/index.spec.ts b/src/api/__tests__/index.spec.ts index 2fe940f53a..e41d603448 100644 --- a/src/api/__tests__/index.spec.ts +++ b/src/api/__tests__/index.spec.ts @@ -48,6 +48,7 @@ import { MistralHandler, MoonshotHandler, NanoGptHandler, + NeuronPoolHandler, OpenAiCodexHandler, OpenAiHandler, OpenAiNativeHandler, @@ -97,6 +98,7 @@ const expectedHandlers = { [providerIdentifiers.zai]: ZAiHandler, [providerIdentifiers.fireworks]: FireworksHandler, [providerIdentifiers.friendli]: FriendliHandler, + [providerIdentifiers.neuronpool]: NeuronPoolHandler, [providerIdentifiers.vercelAiGateway]: VercelAiGatewayHandler, [providerIdentifiers.opencodeGo]: OpencodeGoHandler, [providerIdentifiers.kenari]: KenariHandler, diff --git a/src/api/index.ts b/src/api/index.ts index f45412e32c..29f8ed1ab0 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -39,6 +39,7 @@ import { ZAiHandler, FireworksHandler, FriendliHandler, + NeuronPoolHandler, VercelAiGatewayHandler, OpencodeGoHandler, KenariHandler, @@ -219,6 +220,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { return new FireworksHandler(options) case providerIdentifiers.friendli: return new FriendliHandler(options) + case providerIdentifiers.neuronpool: + return new NeuronPoolHandler(options) case providerIdentifiers.vercelAiGateway: return new VercelAiGatewayHandler(options) case providerIdentifiers.opencodeGo: diff --git a/src/api/providers/__tests__/neuronpool.spec.ts b/src/api/providers/__tests__/neuronpool.spec.ts new file mode 100644 index 0000000000..aedbbfe12e --- /dev/null +++ b/src/api/providers/__tests__/neuronpool.spec.ts @@ -0,0 +1,270 @@ +// npx vitest run src/api/providers/__tests__/neuronpool.spec.ts + +import OpenAI from "openai" +import { Anthropic } from "@anthropic-ai/sdk" + +import { type NeuronPoolModelId, neuronpoolDefaultModelId, neuronpoolModels } from "@roo-code/types" + +import { + NeuronPoolHandler, + NEURONPOOL_DEFAULT_BASE_URL, + neuronpoolDefaultBaseUrl, + resolveNeuronpoolBaseUrl, + stripTrailingSlashes, +} from "../neuronpool" + +const LIVE_WORKER_BASE_URL = ["https://neuronpool.damnknee.workers.dev", "v1"].join("/") + +const mockCreate = vi.hoisted(() => vi.fn()) + +vi.mock("openai", () => ({ + default: vi.fn(function () { + return { + chat: { + completions: { + create: mockCreate, + }, + }, + } + }), +})) + +describe("neuronpoolDefaultBaseUrl", () => { + it("returns the live Worker /v1 URL", () => { + expect(neuronpoolDefaultBaseUrl()).toBe(LIVE_WORKER_BASE_URL) + expect(NEURONPOOL_DEFAULT_BASE_URL).toBe(LIVE_WORKER_BASE_URL) + }) +}) + +describe("stripTrailingSlashes", () => { + it("leaves a URL without trailing slashes unchanged", () => { + expect(stripTrailingSlashes(LIVE_WORKER_BASE_URL)).toBe(LIVE_WORKER_BASE_URL) + }) + + it("strips one or many trailing slashes without regex backtracking", () => { + expect(stripTrailingSlashes("https://example.test/v1/")).toBe("https://example.test/v1") + expect(stripTrailingSlashes("https://example.test/v1" + "/".repeat(64))).toBe("https://example.test/v1") + }) + + it("treats empty and slash-only strings as empty", () => { + expect(stripTrailingSlashes("")).toBe("") + expect(stripTrailingSlashes("/")).toBe("") + expect(stripTrailingSlashes("///")).toBe("") + }) + + it("does not strip trailing characters that are not slashes", () => { + expect(stripTrailingSlashes("https://example.test/v1 ")).toBe("https://example.test/v1 ") + expect(stripTrailingSlashes("https://example.test/v1.")).toBe("https://example.test/v1.") + }) +}) + +describe("resolveNeuronpoolBaseUrl", () => { + it("uses the live Worker URL when the custom value is empty", () => { + expect(resolveNeuronpoolBaseUrl()).toBe(LIVE_WORKER_BASE_URL) + expect(resolveNeuronpoolBaseUrl("")).toBe(LIVE_WORKER_BASE_URL) + expect(resolveNeuronpoolBaseUrl(" ")).toBe(LIVE_WORKER_BASE_URL) + }) + + it("rejects slash-only custom URLs that normalize to empty", () => { + expect(() => resolveNeuronpoolBaseUrl("/")).toThrow("NeuronPool base URL is required") + expect(() => resolveNeuronpoolBaseUrl("///")).toThrow("NeuronPool base URL is required") + }) + + it("rejects values that are not absolute URLs", () => { + expect(() => resolveNeuronpoolBaseUrl("not-a-url")).toThrow("NeuronPool base URL is invalid") + }) + + it("rejects non-HTTPS remote endpoints", () => { + expect(() => resolveNeuronpoolBaseUrl("http://evil.example/v1")).toThrow("NeuronPool base URL must use HTTPS") + expect(() => resolveNeuronpoolBaseUrl("ftp://example.test/v1")).toThrow("NeuronPool base URL must use HTTPS") + }) + + it("allows HTTP on loopback for local workers", () => { + expect(resolveNeuronpoolBaseUrl("http://127.0.0.1:8787/v1/")).toBe("http://127.0.0.1:8787/v1") + expect(resolveNeuronpoolBaseUrl("http://localhost:8787/v1")).toBe("http://localhost:8787/v1") + }) + + it("honors a custom HTTPS URL", () => { + expect(resolveNeuronpoolBaseUrl("https://example.test/v1/")).toBe("https://example.test/v1") + }) +}) + +describe("NeuronPoolHandler", () => { + let handler: NeuronPoolHandler + + beforeEach(() => { + vi.clearAllMocks() + handler = new NeuronPoolHandler({ neuronpoolApiKey: "test-neuronpool-api-key" }) + }) + + it("should use the live Worker base URL by default", () => { + new NeuronPoolHandler({ neuronpoolApiKey: "test-neuronpool-api-key" }) + expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: LIVE_WORKER_BASE_URL })) + }) + + it("should fall back to the live Worker URL when the custom base URL is empty", () => { + new NeuronPoolHandler({ + neuronpoolApiKey: "test-neuronpool-api-key", + neuronpoolBaseUrl: "", + }) + expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: LIVE_WORKER_BASE_URL })) + }) + + it("should honor a custom base URL", () => { + new NeuronPoolHandler({ + neuronpoolApiKey: "test-neuronpool-api-key", + neuronpoolBaseUrl: "https://example.test/v1/", + }) + expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: "https://example.test/v1" })) + }) + + it("does not construct the OpenAI client for slash-only custom URLs", () => { + vi.mocked(OpenAI).mockClear() + expect(() => + new NeuronPoolHandler({ + neuronpoolApiKey: "test-neuronpool-api-key", + neuronpoolBaseUrl: "///", + }), + ).toThrow("NeuronPool base URL is required") + expect(OpenAI).not.toHaveBeenCalled() + }) + + it("does not construct the OpenAI client for remote HTTP URLs", () => { + vi.mocked(OpenAI).mockClear() + expect(() => + new NeuronPoolHandler({ + neuronpoolApiKey: "test-neuronpool-api-key", + neuronpoolBaseUrl: "http://evil.example/v1", + }), + ).toThrow("NeuronPool base URL must use HTTPS") + expect(OpenAI).not.toHaveBeenCalled() + }) + + it("should use the provided API key", () => { + const neuronpoolApiKey = "test-neuronpool-api-key" + new NeuronPoolHandler({ neuronpoolApiKey }) + expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: neuronpoolApiKey })) + }) + + it("does not use a generic apiKey as the NeuronPool credential", () => { + vi.mocked(OpenAI).mockClear() + expect(() => new NeuronPoolHandler({ apiKey: "sk-ant-should-not-leak" })).toThrow("API key is required") + expect(OpenAI).not.toHaveBeenCalled() + }) + + it("should throw when neuronpoolApiKey is unset", () => { + vi.mocked(OpenAI).mockClear() + expect(() => new NeuronPoolHandler({})).toThrow("API key is required") + expect(OpenAI).not.toHaveBeenCalled() + }) + + it("sets the provider name to NeuronPool", () => { + expect(handler["providerName"]).toBe("NeuronPool") + }) + + it("should return default model when no model is specified", () => { + const model = handler.getModel() + expect(model.id).toBe(neuronpoolDefaultModelId) + expect(model.info).toEqual(neuronpoolModels[neuronpoolDefaultModelId]) + }) + + it("should return specified model when valid model is provided", () => { + const testModelId: NeuronPoolModelId = "llama-3.2-1b-instruct" + const handlerWithModel = new NeuronPoolHandler({ + apiModelId: testModelId, + neuronpoolApiKey: "test-neuronpool-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual(neuronpoolModels[testModelId]) + }) + + it("should fall back to the default model when the id is unknown", () => { + const handlerWithUnknown = new NeuronPoolHandler({ + apiModelId: "not-a-neuronpool-model", + neuronpoolApiKey: "test-neuronpool-api-key", + }) + const model = handlerWithUnknown.getModel() + expect(model.id).toBe(neuronpoolDefaultModelId) + expect(model.info).toEqual(neuronpoolModels[neuronpoolDefaultModelId]) + }) + + it("completePrompt method should return text from NeuronPool API", async () => { + const expectedResponse = "pong" + mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] }) + const result = await handler.completePrompt("ping") + expect(result).toBe(expectedResponse) + }) + + it("should handle errors in completePrompt", async () => { + const errorMessage = "NeuronPool API error" + mockCreate.mockRejectedValueOnce(new Error(errorMessage)) + await expect(handler.completePrompt("test prompt")).rejects.toThrow() + }) + + it("createMessage should yield text content from stream", async () => { + const testContent = "pong" + + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + next: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: { choices: [{ delta: { content: testContent } }] }, + }) + .mockResolvedValueOnce({ done: true }), + }), + } + }) + + const stream = handler.createMessage("system prompt", []) + const firstChunk = await stream.next() + + expect(firstChunk.done).toBe(false) + expect(firstChunk.value).toEqual({ type: "text", text: testContent }) + }) + + it("should pass stream + tools through to the OpenAI-compatible API", async () => { + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + next: vi.fn().mockResolvedValueOnce({ done: true }), + }), + } + }) + + const echoTool = { + type: "function" as const, + function: { + name: "echo", + description: "Echo text", + parameters: { type: "object", properties: { text: { type: "string" } }, required: ["text"] }, + }, + } + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] + const stream = handler.createMessage("You are a helpful assistant", messages, { + taskId: "neuronpool-tool-test", + tools: [echoTool], + }) + for await (const _chunk of stream) { + // drain + } + + const callArgs = mockCreate.mock.calls[0][0] + expect(callArgs.model).toBe(neuronpoolDefaultModelId) + expect(callArgs.stream).toBe(true) + expect(callArgs.temperature).toBe(0) + expect(callArgs.tools).toEqual([ + expect.objectContaining({ + type: "function", + function: expect.objectContaining({ + name: "echo", + description: "Echo text", + strict: true, + }), + }), + ]) + }) +}) diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index 8ba1eae382..0105e81a54 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -27,6 +27,7 @@ export { XAIHandler } from "./xai" export { ZAiHandler } from "./zai" export { FireworksHandler } from "./fireworks" export { FriendliHandler } from "./friendli" +export { NeuronPoolHandler } from "./neuronpool" export { VercelAiGatewayHandler } from "./vercel-ai-gateway" export { OpencodeGoHandler } from "./opencode-go" export { KenariHandler } from "./kenari" diff --git a/src/api/providers/neuronpool.ts b/src/api/providers/neuronpool.ts new file mode 100644 index 0000000000..d83145035b --- /dev/null +++ b/src/api/providers/neuronpool.ts @@ -0,0 +1,66 @@ +import { type NeuronPoolModelId, neuronpoolDefaultModelId, neuronpoolModels } from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../shared/api" + +import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" + +/** Live Worker OpenAI-compatible origin (`…/v1`). */ +export function neuronpoolDefaultBaseUrl(): string { + return ["https://neuronpool.damnknee.workers.dev", "v1"].join("/") +} + +export const NEURONPOOL_DEFAULT_BASE_URL = neuronpoolDefaultBaseUrl() + +/** Trim trailing slashes without a `/+` regex (CodeQL js/polynomial-redos). */ +export function stripTrailingSlashes(url: string): string { + let end = url.length + // Stryker disable next-line ConditionalExpression,EqualityOperator: end>0 vs >=0 is equivalent; charCodeAt(-1) is never 47 + while (end > 0 && url.charCodeAt(end - 1) === 47) { + end -= 1 + } + return url.slice(0, end) +} + +/** Resolve a custom NeuronPool URL. Empty input uses the default; slash-only and non-HTTPS remote URLs are rejected. */ +export function resolveNeuronpoolBaseUrl(raw?: string): string { + const trimmed = typeof raw === "string" ? raw.trim() : "" + if (trimmed.length === 0) { + return neuronpoolDefaultBaseUrl() + } + const normalized = stripTrailingSlashes(trimmed) + if (normalized.length === 0) { + throw new Error("NeuronPool base URL is required") + } + let parsed: URL + try { + parsed = new URL(normalized) + } catch { + throw new Error("NeuronPool base URL is invalid") + } + const host = parsed.hostname.toLowerCase() + // Stryker disable next-line ConditionalExpression,StringLiteral,LogicalOperator: loopback HTTP is an explicit local-dev allowlist + const loopback = host === "localhost" || host === "127.0.0.1" || host === "::1" + const httpsOk = parsed.protocol === "https:" + // Stryker disable next-line ConditionalExpression,LogicalOperator: http+loopback is the only allowed cleartext path; remote HTTP is rejected below + const loopbackHttpOk = parsed.protocol === "http:" && loopback + // Stryker disable next-line ConditionalExpression,LogicalOperator: allow predicate is https or loopback-http; complements covered by throw tests + if (httpsOk || loopbackHttpOk) { + return normalized + } + throw new Error("NeuronPool base URL must use HTTPS") +} + +/** OpenAI-compatible handler. Uses only `neuronpoolApiKey` — never the shared Anthropic `apiKey`. */ +export class NeuronPoolHandler extends BaseOpenAiCompatibleProvider { + constructor(options: ApiHandlerOptions) { + super({ + ...options, + providerName: "NeuronPool", + baseURL: resolveNeuronpoolBaseUrl(options.neuronpoolBaseUrl), + apiKey: options.neuronpoolApiKey, + defaultProviderModelId: neuronpoolDefaultModelId, + providerModels: neuronpoolModels, + defaultTemperature: 0, + }) + } +} diff --git a/src/shared/ProfileValidator.ts b/src/shared/ProfileValidator.ts index 923582bd06..e5223bb460 100644 --- a/src/shared/ProfileValidator.ts +++ b/src/shared/ProfileValidator.ts @@ -64,6 +64,7 @@ export class ProfileValidator { case providerIdentifiers.sambanova: case providerIdentifiers.fireworks: case providerIdentifiers.friendli: + case providerIdentifiers.neuronpool: return profile.apiModelId case providerIdentifiers.litellm: return profile.litellmModelId diff --git a/src/shared/__tests__/ProfileValidator.spec.ts b/src/shared/__tests__/ProfileValidator.spec.ts index 865fa5cf51..118425f9e9 100644 --- a/src/shared/__tests__/ProfileValidator.spec.ts +++ b/src/shared/__tests__/ProfileValidator.spec.ts @@ -19,6 +19,7 @@ describe("ProfileValidator", () => { ["sambanova", { apiModelId: "model" }], ["fireworks", { apiModelId: "model" }], ["friendli", { apiModelId: "model" }], + ["neuronpool", { apiModelId: "model" }], ["litellm", { litellmModelId: "model" }], ["lmstudio", { lmStudioModelId: "model" }], ["vscodeLm", { vsCodeLmModelSelector: { id: "model" } }], @@ -246,6 +247,7 @@ describe("ProfileValidator", () => { providerIdentifiers.sambanova, providerIdentifiers.fireworks, providerIdentifiers.friendli, + providerIdentifiers.neuronpool, ] apiModelProviders.forEach((provider) => { diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 3e1495baff..f0a9b771d3 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -78,6 +78,7 @@ import { ZAi, Fireworks, Friendli, + NeuronPool, VercelAiGateway, OpenCodeGo, Kenari, @@ -718,6 +719,13 @@ const ApiOptions = ({ /> )} + {selectedProvider === providerIdentifiers.neuronpool && ( + + )} + {selectedProvider === providerIdentifiers.poe && ( { ZAi: provider("provider-zai"), Fireworks: provider("provider-fireworks"), Friendli: provider("provider-friendli"), + NeuronPool: provider("provider-neuronpool"), VercelAiGateway: provider("provider-vercel-ai-gateway"), OpenCodeGo: provider("provider-opencode-go"), Kenari: provider("provider-kenari"), @@ -355,6 +356,7 @@ describe("ApiOptions interactions", () => { providerIdentifiers.zooGateway, providerIdentifiers.fireworks, providerIdentifiers.friendli, + providerIdentifiers.neuronpool, providerIdentifiers.poe, ])("renders the %s provider branch when selected", (apiProvider) => { renderApiOptions({ apiConfiguration: { apiProvider } }) diff --git a/webview-ui/src/components/settings/__tests__/ApiOptions.neuronpool.spec.tsx b/webview-ui/src/components/settings/__tests__/ApiOptions.neuronpool.spec.tsx new file mode 100644 index 0000000000..cca3cba518 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/ApiOptions.neuronpool.spec.tsx @@ -0,0 +1,138 @@ +import type { ReactNode } from "react" +import { render, screen } from "@/utils/test-utils" +import { providerIdentifiers } from "@roo-code/types" + +import ApiOptions from "../ApiOptions" + +type ChildrenProps = { children?: ReactNode } + +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + organizationAllowList: { allowAll: true, providers: {} }, + openAiCodexIsAuthenticated: false, + kimiCodeIsAuthenticated: false, + kimiCodeOAuthState: undefined, + }), +})) + +vi.mock("@src/components/ui/hooks/useRouterModels", () => ({ + useRouterModels: () => ({ data: {}, refetch: vi.fn() }), +})) + +vi.mock("@src/components/ui/hooks/useZooGatewayRouterModelsSync", () => ({ + useZooGatewayRouterModelsSync: vi.fn(), +})) + +vi.mock("@src/components/ui/hooks/useOpenRouterModelProviders", () => ({ + useOpenRouterModelProviders: () => ({ data: undefined }), + OPENROUTER_DEFAULT_PROVIDER_NAME: "Auto", +})) + +vi.mock("@src/components/ui/hooks/useSelectedModel", () => ({ + useSelectedModel: (configuration: { apiProvider?: string; apiModelId?: string }) => ({ + provider: configuration.apiProvider, + id: configuration.apiModelId, + info: {}, + }), +})) + +vi.mock("@src/components/ui/hooks/useLmStudioModels", () => ({ + requestLmStudioModels: vi.fn(), +})) + +vi.mock("../providers", () => { + const provider = (testId: string) => () =>
+ return { + Anthropic: provider("provider-anthropic"), + Baseten: provider("provider-baseten"), + Bedrock: provider("provider-bedrock"), + DeepSeek: provider("provider-deepseek"), + Gemini: provider("provider-gemini"), + LMStudio: provider("provider-lmstudio"), + LiteLLM: provider("provider-litellm"), + Mistral: provider("provider-mistral"), + Moonshot: provider("provider-moonshot"), + KimiCode: provider("provider-kimi-code"), + Ollama: provider("provider-ollama"), + OpenAI: provider("provider-openai-native"), + OpenAICompatible: provider("provider-openai"), + OpenAICodex: provider("provider-openai-codex"), + OpenRouter: provider("provider-openrouter"), + Poe: provider("provider-poe"), + QwenCode: provider("provider-qwen-code"), + Requesty: provider("provider-requesty"), + SambaNova: provider("provider-sambanova"), + Unbound: provider("provider-unbound"), + Vertex: provider("provider-vertex"), + VSCodeLM: provider("provider-vscode-lm"), + XAI: provider("provider-xai"), + ZAi: provider("provider-zai"), + Fireworks: provider("provider-fireworks"), + Friendli: provider("provider-friendli"), + NeuronPool: provider("provider-neuronpool"), + VercelAiGateway: provider("provider-vercel-ai-gateway"), + OpenCodeGo: provider("provider-opencode-go"), + Kenari: provider("provider-kenari"), + NanoGPT: provider("provider-nanogpt"), + ZooGateway: provider("provider-zoo-gateway"), + MiniMax: provider("provider-minimax"), + Mimo: provider("provider-mimo"), + } +}) + +vi.mock("../ModelPicker", () => ({ ModelPicker: () => null })) +vi.mock("../ApiErrorMessage", () => ({ ApiErrorMessage: () => null })) +vi.mock("../ThinkingBudget", () => ({ ThinkingBudget: () => null })) +vi.mock("../Verbosity", () => ({ Verbosity: () => null })) +vi.mock("../TodoListSettingsControl", () => ({ TodoListSettingsControl: () => null })) +vi.mock("../TemperatureControl", () => ({ TemperatureControl: () => null })) +vi.mock("../RateLimitSecondsControl", () => ({ RateLimitSecondsControl: () => null })) +vi.mock("../ConsecutiveMistakeLimitControl", () => ({ ConsecutiveMistakeLimitControl: () => null })) + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeTextField: ({ children }: ChildrenProps) => , + VSCodeLink: ({ children }: ChildrenProps) => {children}, +})) + +vi.mock("@/components/ui", () => ({ + SearchableSelect: () =>
, + Collapsible: ({ children }: ChildrenProps) =>
{children}
, + CollapsibleTrigger: ({ children }: ChildrenProps) =>
{children}
, + CollapsibleContent: ({ children }: ChildrenProps) =>
{children}
, + Select: ({ children }: ChildrenProps) =>
{children}
, + SelectTrigger: ({ children }: ChildrenProps) =>
{children}
, + SelectValue: ({ children }: ChildrenProps) =>
{children}
, + SelectContent: ({ children }: ChildrenProps) =>
{children}
, + SelectItem: ({ children }: ChildrenProps) =>
{children}
, + Button: ({ children }: ChildrenProps) => , + StandardTooltip: ({ children }: ChildrenProps) =>
{children}
, +})) + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key }), +})) + +const renderApiOptions = (apiProvider: typeof providerIdentifiers.friendli | typeof providerIdentifiers.neuronpool) => + render( + undefined} + uriScheme={undefined} + apiConfiguration={{ apiProvider }} + setApiConfigurationField={vi.fn()} + />, + ) + +describe("ApiOptions NeuronPool branch", () => { + it("does not render NeuronPool when another provider is selected", () => { + renderApiOptions(providerIdentifiers.friendli) + expect(screen.getByTestId("provider-friendli")).toBeInTheDocument() + expect(screen.queryByTestId("provider-neuronpool")).not.toBeInTheDocument() + }) + + it("renders NeuronPool when that provider is selected", () => { + renderApiOptions(providerIdentifiers.neuronpool) + expect(screen.getByTestId("provider-neuronpool")).toBeInTheDocument() + expect(screen.queryByTestId("provider-friendli")).not.toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx b/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx index b0f1b6f9ef..c093968d85 100644 --- a/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx @@ -251,6 +251,12 @@ vi.mock("../providers/Friendli", () => ({ ), })) +vi.mock("../providers/NeuronPool", () => ({ + NeuronPool: ({ apiConfiguration }: any) => ( +
+ ), +})) + vi.mock("@src/components/ui/hooks/useSelectedModel", () => ({ useSelectedModel: vi.fn((apiConfiguration: ProviderSettings) => { if (apiConfiguration.apiModelId?.includes("thinking")) { @@ -350,6 +356,18 @@ describe("ApiOptions", () => { expect(screen.getByTestId("friendli-provider")).toHaveAttribute("data-key", "k") }) + it("renders the NeuronPool provider form when neuronpool is selected", () => { + renderApiOptions({ + apiConfiguration: { + apiProvider: providerIdentifiers.neuronpool, + neuronpoolApiKey: "k", + }, + }) + + expect(screen.getByTestId("neuronpool-provider")).toBeInTheDocument() + expect(screen.getByTestId("neuronpool-provider")).toHaveAttribute("data-key", "k") + }) + it("shows temperature and rate limit controls by default", () => { renderApiOptions({ apiConfiguration: {}, diff --git a/webview-ui/src/components/settings/__tests__/constants.neuronpool.spec.ts b/webview-ui/src/components/settings/__tests__/constants.neuronpool.spec.ts new file mode 100644 index 0000000000..a8a570c556 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/constants.neuronpool.spec.ts @@ -0,0 +1,22 @@ +import { neuronpoolModels, providerIdentifiers } from "@roo-code/types" + +import { MODELS_BY_PROVIDER, PROVIDERS } from "../constants" + +const pinnedProviders = [ + { value: providerIdentifiers.friendli, label: "Friendli", proxy: false }, + { value: providerIdentifiers.neuronpool, label: "NeuronPool", proxy: false }, + { value: providerIdentifiers.vercelAiGateway, label: "Vercel AI Gateway", proxy: false }, +] as const + +describe("NeuronPool constants", () => { + it("registers NeuronPool between the adjacent dropdown rows", () => { + for (const row of pinnedProviders) { + expect(PROVIDERS.find((provider) => provider.value === row.value)).toEqual(row) + } + }) + + it("registers the static NeuronPool catalog", () => { + expect(MODELS_BY_PROVIDER[providerIdentifiers.neuronpool]).toEqual(neuronpoolModels) + expect(MODELS_BY_PROVIDER[providerIdentifiers.friendli]).toBeDefined() + }) +}) diff --git a/webview-ui/src/components/settings/__tests__/constants.spec.ts b/webview-ui/src/components/settings/__tests__/constants.spec.ts new file mode 100644 index 0000000000..93434199f1 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/constants.spec.ts @@ -0,0 +1,21 @@ +import { neuronpoolModels, providerIdentifiers } from "@roo-code/types" + +import { MODELS_BY_PROVIDER, PROVIDERS } from "../constants" + +const pinnedProviders = [ + { value: providerIdentifiers.friendli, label: "Friendli", proxy: false }, + { value: providerIdentifiers.neuronpool, label: "NeuronPool", proxy: false }, + { value: providerIdentifiers.vercelAiGateway, label: "Vercel AI Gateway", proxy: false }, +] as const + +describe("settings provider constants", () => { + it("keeps NeuronPool and its adjacent dropdown rows intact", () => { + for (const row of pinnedProviders) { + expect(PROVIDERS.find((provider) => provider.value === row.value)).toEqual(row) + } + }) + + it("registers the static NeuronPool catalog", () => { + expect(MODELS_BY_PROVIDER[providerIdentifiers.neuronpool]).toEqual(neuronpoolModels) + }) +}) diff --git a/webview-ui/src/components/settings/__tests__/providerModelConfig.neuronpool.spec.ts b/webview-ui/src/components/settings/__tests__/providerModelConfig.neuronpool.spec.ts new file mode 100644 index 0000000000..1f25fb364b --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/providerModelConfig.neuronpool.spec.ts @@ -0,0 +1,27 @@ +import { neuronpoolDefaultModelId, friendliDefaultModelId, providerIdentifiers } from "@roo-code/types" + +import { getProviderModelConfig, getProviderServiceConfig } from "../utils/providerModelConfig" + +describe("NeuronPool provider model config", () => { + it("exposes the live Worker dashboard as the service URL", () => { + expect(getProviderServiceConfig(providerIdentifiers.neuronpool)).toEqual({ + serviceName: "NeuronPool", + serviceUrl: "https://neuronpool.damnknee.workers.dev/dashboard", + }) + expect(getProviderServiceConfig(providerIdentifiers.friendli)).toEqual({ + serviceName: "Friendli", + serviceUrl: "https://friendli.ai", + }) + }) + + it("wires apiModelId to the NeuronPool default model", () => { + expect(getProviderModelConfig(providerIdentifiers.neuronpool)).toEqual({ + field: "apiModelId", + default: neuronpoolDefaultModelId, + }) + expect(getProviderModelConfig(providerIdentifiers.friendli)).toEqual({ + field: "apiModelId", + default: friendliDefaultModelId, + }) + }) +}) diff --git a/webview-ui/src/components/settings/constants.ts b/webview-ui/src/components/settings/constants.ts index 8c51aca2fe..849b0a40da 100644 --- a/webview-ui/src/components/settings/constants.ts +++ b/webview-ui/src/components/settings/constants.ts @@ -17,6 +17,7 @@ import { internationalZAiModels, fireworksModels, friendliModels, + neuronpoolModels, minimaxModels, basetenModels, mimoModels, @@ -38,6 +39,7 @@ export const MODELS_BY_PROVIDER: Partial { value: providerIdentifiers.zai, label: "Z.ai", proxy: false }, { value: providerIdentifiers.fireworks, label: "Fireworks AI", proxy: false }, { value: providerIdentifiers.friendli, label: "Friendli", proxy: false }, + // Stryker disable next-line ObjectLiteral,StringLiteral,BooleanLiteral: static NeuronPool dropdown row; empty object, empty label, or flipped proxy cannot satisfy identifier lookup + { value: providerIdentifiers.neuronpool, label: "NeuronPool", proxy: false }, + // Stryker disable next-line ObjectLiteral: adjacent hunk neighbor of the NeuronPool dropdown insert { value: providerIdentifiers.vercelAiGateway, label: "Vercel AI Gateway", proxy: false }, { value: providerIdentifiers.opencodeGo, label: "Opencode Go", proxy: false }, { value: providerIdentifiers.kenari, label: "Kenari", proxy: false }, diff --git a/webview-ui/src/components/settings/providers/NeuronPool.tsx b/webview-ui/src/components/settings/providers/NeuronPool.tsx new file mode 100644 index 0000000000..498feb4761 --- /dev/null +++ b/webview-ui/src/components/settings/providers/NeuronPool.tsx @@ -0,0 +1,58 @@ +import { useCallback } from "react" +import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" + +import type { ProviderSettings } from "@roo-code/types" + +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" + +import { inputEventTransform } from "../transforms" + +type NeuronPoolProps = { + apiConfiguration: ProviderSettings + setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void +} + +export const NeuronPool = ({ apiConfiguration, setApiConfigurationField }: NeuronPoolProps) => { + const { t } = useAppTranslation() + + const handleInputChange = useCallback( + ( + field: K, + transform: (event: E) => ProviderSettings[K] = inputEventTransform, + ) => + (event: E | Event) => { + setApiConfigurationField(field, transform(event as E)) + }, + [setApiConfigurationField], + ) + + return ( + <> + + + +
+ {t("settings:providers.apiKeyStorageNotice")} +
+ + + + {!apiConfiguration?.neuronpoolApiKey && ( + + Get NeuronPool API Key + + )} + + ) +} diff --git a/webview-ui/src/components/settings/providers/__tests__/NeuronPool.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/NeuronPool.spec.tsx new file mode 100644 index 0000000000..1df0b54bd0 --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/NeuronPool.spec.tsx @@ -0,0 +1,139 @@ +import { providerIdentifiers } from "@roo-code/types" +import React from "react" +import { render, screen, fireEvent } from "@/utils/test-utils" +import type { ProviderSettings } from "@roo-code/types" + +import { NeuronPool } from "../NeuronPool" + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeTextField: ({ children, value, onInput, type, placeholder }: any) => { + const testId = type === "password" ? "neuronpool-api-key" : "neuronpool-base-url" + return ( +
+ {children} + +
+ ) + }, +})) + +vi.mock("@src/components/common/VSCodeButtonLink", () => ({ + VSCodeButtonLink: ({ href, children }: any) => ( + + {children} + + ), +})) + +describe("NeuronPool provider settings", () => { + it("renders labels, placeholders, and the get-key link when no key is set", () => { + render( + , + ) + expect(screen.getByTestId("neuronpool-get-key-link")).toHaveAttribute( + "href", + "https://neuronpool.damnknee.workers.dev/dashboard", + ) + expect(screen.getByText("settings:providers.apiKey")).toBeInTheDocument() + expect(screen.getByText("settings:providers.apiKeyStorageNotice")).toBeInTheDocument() + expect(screen.getByTestId("neuronpool-api-key-input")).toHaveAttribute( + "placeholder", + "settings:placeholders.apiKey", + ) + expect(screen.getByTestId("neuronpool-base-url-input")).toHaveAttribute( + "placeholder", + "https://neuronpool.damnknee.workers.dev/v1", + ) + }) + + it("treats a missing apiConfiguration as empty fields without throwing", () => { + render( + , + ) + expect(screen.getByTestId("neuronpool-api-key-input")).toHaveValue("") + expect(screen.getByTestId("neuronpool-base-url-input")).toHaveValue("") + expect(screen.getByTestId("neuronpool-get-key-link")).toBeInTheDocument() + }) + + it("shows the get-key link when the stored key is an empty string", () => { + render( + , + ) + expect(screen.getByTestId("neuronpool-get-key-link")).toBeInTheDocument() + }) + + it("hides the 'Get NeuronPool API Key' link once a key is set", () => { + render( + , + ) + expect(screen.queryByTestId("neuronpool-get-key-link")).not.toBeInTheDocument() + expect(screen.getByTestId("neuronpool-api-key-input")).toHaveValue("stored-key") + expect(screen.getByTestId("neuronpool-base-url-input")).toHaveValue("http://127.0.0.1:8787/v1") + }) + + it("calls the latest setApiConfigurationField after a rerender", () => { + const first = vi.fn() + const second = vi.fn() + const { rerender } = render( + , + ) + rerender( + , + ) + fireEvent.input(screen.getByTestId("neuronpool-api-key-input"), { target: { value: "new-key" } }) + expect(second).toHaveBeenCalledWith("neuronpoolApiKey", "new-key") + expect(first).not.toHaveBeenCalled() + }) + + it("calls setApiConfigurationField with the base URL when the input changes", () => { + const mockSetApiConfigurationField = vi.fn() + render( + , + ) + fireEvent.input(screen.getByTestId("neuronpool-base-url-input"), { + target: { value: "http://127.0.0.1:8787/v1" }, + }) + expect(mockSetApiConfigurationField).toHaveBeenCalledWith("neuronpoolBaseUrl", "http://127.0.0.1:8787/v1") + }) +}) diff --git a/webview-ui/src/components/settings/providers/index.ts b/webview-ui/src/components/settings/providers/index.ts index 12ea4cd786..4fbc15695d 100644 --- a/webview-ui/src/components/settings/providers/index.ts +++ b/webview-ui/src/components/settings/providers/index.ts @@ -23,6 +23,7 @@ export { ZAi } from "./ZAi" export { LiteLLM } from "./LiteLLM" export { Fireworks } from "./Fireworks" export { Friendli } from "./Friendli" +export { NeuronPool } from "./NeuronPool" export { VercelAiGateway } from "./VercelAiGateway" export { OpenCodeGo } from "./OpenCodeGo" export { Kenari } from "./Kenari" diff --git a/webview-ui/src/components/settings/utils/__tests__/providerModelConfig.neuronpool.spec.ts b/webview-ui/src/components/settings/utils/__tests__/providerModelConfig.neuronpool.spec.ts new file mode 100644 index 0000000000..c5184585d0 --- /dev/null +++ b/webview-ui/src/components/settings/utils/__tests__/providerModelConfig.neuronpool.spec.ts @@ -0,0 +1,37 @@ +import { neuronpoolDefaultModelId, friendliDefaultModelId, providerIdentifiers } from "@roo-code/types" + +import { + PROVIDER_DEFAULT_MODEL_IDS, + PROVIDER_SERVICE_CONFIG, + getProviderModelConfig, + getProviderServiceConfig, +} from "../providerModelConfig" + +describe("NeuronPool provider model config", () => { + it("exposes the live Worker dashboard as the service URL", () => { + expect(PROVIDER_SERVICE_CONFIG[providerIdentifiers.neuronpool]).toEqual({ + serviceName: "NeuronPool", + serviceUrl: "https://neuronpool.damnknee.workers.dev/dashboard", + }) + expect(getProviderServiceConfig(providerIdentifiers.neuronpool)).toEqual({ + serviceName: "NeuronPool", + serviceUrl: "https://neuronpool.damnknee.workers.dev/dashboard", + }) + expect(getProviderServiceConfig(providerIdentifiers.friendli)).toEqual({ + serviceName: "Friendli", + serviceUrl: "https://friendli.ai", + }) + }) + + it("wires apiModelId to the NeuronPool default model", () => { + expect(PROVIDER_DEFAULT_MODEL_IDS[providerIdentifiers.neuronpool]).toBe(neuronpoolDefaultModelId) + expect(getProviderModelConfig(providerIdentifiers.neuronpool)).toEqual({ + field: "apiModelId", + default: neuronpoolDefaultModelId, + }) + expect(getProviderModelConfig(providerIdentifiers.friendli)).toEqual({ + field: "apiModelId", + default: friendliDefaultModelId, + }) + }) +}) diff --git a/webview-ui/src/components/settings/utils/__tests__/providerModelConfig.spec.ts b/webview-ui/src/components/settings/utils/__tests__/providerModelConfig.spec.ts index b695b3529e..0637357a13 100644 --- a/webview-ui/src/components/settings/utils/__tests__/providerModelConfig.spec.ts +++ b/webview-ui/src/components/settings/utils/__tests__/providerModelConfig.spec.ts @@ -2,9 +2,11 @@ import { anthropicDefaultModelId, mainlandZAiDefaultModelId, nanoGptDefaultModelId, + neuronpoolDefaultModelId, providerIdentifiers, } from "@roo-code/types" +import { PROVIDERS } from "../../constants" import { PROVIDER_SERVICE_CONFIG, PROVIDER_DEFAULT_MODEL_IDS, @@ -20,6 +22,16 @@ import { } from "../providerModelConfig" describe("providerModelConfig", () => { + describe("PROVIDERS dropdown", () => { + it("keeps the NeuronPool settings row intact", () => { + expect(PROVIDERS.find((provider) => provider.value === providerIdentifiers.neuronpool)).toEqual({ + value: providerIdentifiers.neuronpool, + label: "NeuronPool", + proxy: false, + }) + }) + }) + describe("PROVIDER_SERVICE_CONFIG", () => { it("uses canonical provider identifiers as registry keys", () => { expect(PROVIDER_SERVICE_CONFIG[providerIdentifiers.openaiNative]?.serviceName).toBe("OpenAI") @@ -60,6 +72,13 @@ describe("providerModelConfig", () => { serviceUrl: "https://code.visualstudio.com/api/extension-guides/language-model", }) }) + + it("contains service config for neuronpool", () => { + expect(PROVIDER_SERVICE_CONFIG[providerIdentifiers.neuronpool]).toEqual({ + serviceName: "NeuronPool", + serviceUrl: "https://neuronpool.damnknee.workers.dev/dashboard", + }) + }) }) describe("getProviderServiceConfig", () => { @@ -74,6 +93,13 @@ describe("providerModelConfig", () => { expect(config.serviceName).toBe("unknown-provider") expect(config.serviceUrl).toBe("") }) + + it("returns NeuronPool dashboard URL for neuronpool", () => { + expect(getProviderServiceConfig(providerIdentifiers.neuronpool)).toEqual({ + serviceName: "NeuronPool", + serviceUrl: "https://neuronpool.damnknee.workers.dev/dashboard", + }) + }) }) describe("PROVIDER_DEFAULT_MODEL_IDS", () => { @@ -82,6 +108,7 @@ describe("providerModelConfig", () => { expect(PROVIDER_DEFAULT_MODEL_IDS[providerIdentifiers.bedrock]).toBeDefined() expect(PROVIDER_DEFAULT_MODEL_IDS[providerIdentifiers.gemini]).toBeDefined() expect(PROVIDER_DEFAULT_MODEL_IDS[providerIdentifiers.openaiNative]).toBeDefined() + expect(PROVIDER_DEFAULT_MODEL_IDS[providerIdentifiers.neuronpool]).toBe(neuronpoolDefaultModelId) }) }) @@ -151,6 +178,10 @@ describe("providerModelConfig", () => { expect(chinaDefault.length).toBeGreaterThan(0) expect(internationalDefault.length).toBeGreaterThan(0) }) + + it("returns the NeuronPool static default", () => { + expect(getDefaultModelIdForProvider(providerIdentifiers.neuronpool)).toBe(neuronpoolDefaultModelId) + }) }) describe("getProviderModelConfig", () => { @@ -181,6 +212,13 @@ describe("providerModelConfig", () => { default: nanoGptDefaultModelId, }) }) + + it("returns NeuronPool's static apiModelId field and default", () => { + expect(getProviderModelConfig(providerIdentifiers.neuronpool)).toEqual({ + field: "apiModelId", + default: neuronpoolDefaultModelId, + }) + }) }) describe("getProviderDocsSlug", () => { diff --git a/webview-ui/src/components/settings/utils/providerModelConfig.ts b/webview-ui/src/components/settings/utils/providerModelConfig.ts index 7230e4cbe4..c8183ad5db 100644 --- a/webview-ui/src/components/settings/utils/providerModelConfig.ts +++ b/webview-ui/src/components/settings/utils/providerModelConfig.ts @@ -19,6 +19,7 @@ import { mainlandZAiDefaultModelId, fireworksDefaultModelId, friendliDefaultModelId, + neuronpoolDefaultModelId, minimaxDefaultModelId, basetenDefaultModelId, mimoDefaultModelId, @@ -61,6 +62,8 @@ export const PROVIDER_SERVICE_CONFIG: Partial> = [providerIdentifiers.zai]: internationalZAiDefaultModelId, [providerIdentifiers.fireworks]: fireworksDefaultModelId, [providerIdentifiers.friendli]: friendliDefaultModelId, + [providerIdentifiers.neuronpool]: neuronpoolDefaultModelId, [providerIdentifiers.minimax]: minimaxDefaultModelId, [providerIdentifiers.mimo]: mimoDefaultModelId, [providerIdentifiers.baseten]: basetenDefaultModelId, @@ -138,6 +142,8 @@ const PROVIDER_MODEL_CONFIG: Partial> [providerIdentifiers.zai]: { field: "apiModelId" }, [providerIdentifiers.fireworks]: { field: "apiModelId", default: fireworksDefaultModelId }, [providerIdentifiers.friendli]: { field: "apiModelId", default: friendliDefaultModelId }, + // Stryker disable next-line ObjectLiteral,StringLiteral: static NeuronPool model-field row; empty object or empty field name cannot satisfy identifier lookup + [providerIdentifiers.neuronpool]: { field: "apiModelId", default: neuronpoolDefaultModelId }, [providerIdentifiers.poe]: { field: "apiModelId", default: poeDefaultModelId }, [providerIdentifiers.vercelAiGateway]: { field: "vercelAiGatewayModelId", diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index 33a9df16da..7f71859c9b 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -22,6 +22,8 @@ import { minimaxModels, friendliDefaultModelId, friendliModels, + neuronpoolDefaultModelId, + neuronpoolModels, deepSeekDefaultModelId, deepSeekModels, openRouterDefaultModelId, @@ -1327,6 +1329,53 @@ describe("useSelectedModel", () => { }) }) + describe("neuronpool provider", () => { + beforeEach(() => { + mockUseRouterModels.mockReturnValue({ + data: { + openrouter: {}, + requesty: {}, + litellm: {}, + }, + isLoading: false, + isError: false, + } as any) + + mockUseOpenRouterModelProviders.mockReturnValue({ + data: {}, + isLoading: false, + isError: false, + } as any) + }) + + it("should return default NeuronPool model when no custom model is specified", () => { + const apiConfiguration: ProviderSettings = { + apiProvider: providerIdentifiers.neuronpool, + } + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) + + expect(result.current.provider).toBe(providerIdentifiers.neuronpool) + expect(result.current.id).toBe(neuronpoolDefaultModelId) + expect(result.current.info).toEqual(neuronpoolModels[neuronpoolDefaultModelId]) + }) + + it("should use custom model ID and info when model exists in neuronpoolModels", () => { + const apiConfiguration: ProviderSettings = { + apiProvider: providerIdentifiers.neuronpool, + apiModelId: "llama-3.2-1b-instruct", + } + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) + + expect(result.current.provider).toBe(providerIdentifiers.neuronpool) + expect(result.current.id).toBe("llama-3.2-1b-instruct") + expect(result.current.info).toEqual(neuronpoolModels["llama-3.2-1b-instruct"]) + }) + }) + describe("Z AI provider", () => { it("uses the International Coding catalog when no API line is configured", () => { const apiConfiguration: ProviderSettings = { diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index b7ad9e87e5..29204cc165 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -24,6 +24,7 @@ import { zaiApiLineConfigs, fireworksModels, friendliModels, + neuronpoolModels, basetenModels, qwenCodeModels, kimiCodeDefaultModelInfo, @@ -389,6 +390,11 @@ function getSelectedModel({ const info = friendliModels[id as keyof typeof friendliModels] return { id, info } } + case providerIdentifiers.neuronpool: { + const id = apiConfiguration.apiModelId ?? defaultModelId + const info = neuronpoolModels[id as keyof typeof neuronpoolModels] + return { id, info } + } case providerIdentifiers.poe: { const id = apiConfiguration.apiModelId ?? defaultModelId const info = routerModels[providerIdentifiers.poe]?.[id] diff --git a/webview-ui/src/utils/__tests__/validate.neuronpool.spec.ts b/webview-ui/src/utils/__tests__/validate.neuronpool.spec.ts new file mode 100644 index 0000000000..90222d55aa --- /dev/null +++ b/webview-ui/src/utils/__tests__/validate.neuronpool.spec.ts @@ -0,0 +1,37 @@ +import { providerIdentifiers, type ProviderSettings } from "@roo-code/types" + +vi.mock("i18next", () => ({ + default: { + t: (key: string) => key, + }, +})) + +import { validateApiConfigurationExcludingModelErrors } from "../validate" + +describe("NeuronPool key validation", () => { + it("returns an apiKey error when neuronpoolApiKey is missing", () => { + const config: ProviderSettings = { + apiProvider: providerIdentifiers.neuronpool, + apiModelId: "gpt-oss-20b", + } + expect(validateApiConfigurationExcludingModelErrors(config)).toBe("settings:validation.apiKey") + }) + + it("accepts neuronpoolApiKey", () => { + const config: ProviderSettings = { + apiProvider: providerIdentifiers.neuronpool, + neuronpoolApiKey: "valid-key", + apiModelId: "gpt-oss-20b", + } + expect(validateApiConfigurationExcludingModelErrors(config)).toBeUndefined() + }) + + it("does not accept a generic apiKey as the NeuronPool credential", () => { + const config: ProviderSettings = { + apiProvider: providerIdentifiers.neuronpool, + apiKey: "sk-ant-should-not-leak", + apiModelId: "gpt-oss-20b", + } + expect(validateApiConfigurationExcludingModelErrors(config)).toBe("settings:validation.apiKey") + }) +}) diff --git a/webview-ui/src/utils/__tests__/validate.spec.ts b/webview-ui/src/utils/__tests__/validate.spec.ts index a1936b89d7..064662ed33 100644 --- a/webview-ui/src/utils/__tests__/validate.spec.ts +++ b/webview-ui/src/utils/__tests__/validate.spec.ts @@ -470,6 +470,29 @@ describe("Model Validation Functions", () => { }) }) + describe("NeuronPool validation", () => { + it("returns an apiKey error when the NeuronPool API key is missing", () => { + const config: ProviderSettings = { + apiProvider: providerIdentifiers.neuronpool, + apiModelId: "gpt-oss-20b", + } + + const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization) + expect(result).toBe("settings:validation.apiKey") + }) + + it("returns undefined for a valid NeuronPool configuration", () => { + const config: ProviderSettings = { + apiProvider: providerIdentifiers.neuronpool, + neuronpoolApiKey: "valid-key", + apiModelId: "gpt-oss-20b", + } + + const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization) + expect(result).toBeUndefined() + }) + }) + describe("Zoo Gateway validation", () => { describe("validateApiConfiguration (welcome-view entry point)", () => { it("returns a sign-in error when neither profile token nor Zoo auth is present", () => { diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index 3e32116374..b74c4d3caa 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -157,6 +157,11 @@ function validateModelsAndKeysProvided( return i18next.t("settings:validation.apiKey") } break + case providerIdentifiers.neuronpool: + if (!apiConfiguration.neuronpoolApiKey) { + return i18next.t("settings:validation.apiKey") + } + break case providerIdentifiers.qwenCode: if (!apiConfiguration.qwenCodeOauthPath) { return i18next.t("settings:validation.qwenCodeOauthPath")