diff --git a/apps/cli/src/lib/utils/__tests__/context-window.test.ts b/apps/cli/src/lib/utils/__tests__/context-window.test.ts index 8d33ef5e2b..4347292166 100644 --- a/apps/cli/src/lib/utils/__tests__/context-window.test.ts +++ b/apps/cli/src/lib/utils/__tests__/context-window.test.ts @@ -14,6 +14,7 @@ describe("getContextWindow", () => { [providerIdentifiers.vercelAiGateway, "vercelAiGatewayModelId"], [providerIdentifiers.opencodeGo, "opencodeGoModelId"], [providerIdentifiers.kenari, "kenariModelId"], + [providerIdentifiers.nanogpt, "nanoGptModelId"], [providerIdentifiers.zooGateway, "zooGatewayModelId"], ] as const)("uses the provider-specific model field for %s", (provider, modelField) => { const config = { apiProvider: provider, [modelField]: "selected-model" } as ProviderSettings diff --git a/apps/cli/src/lib/utils/context-window.ts b/apps/cli/src/lib/utils/context-window.ts index 1d6402c525..b78ab0e5b5 100644 --- a/apps/cli/src/lib/utils/context-window.ts +++ b/apps/cli/src/lib/utils/context-window.ts @@ -56,6 +56,8 @@ function getModelIdForProvider(config: ProviderSettings): string | undefined { return config.opencodeGoModelId case providerIdentifiers.kenari: return config.kenariModelId + case providerIdentifiers.nanogpt: + return config.nanoGptModelId case providerIdentifiers.zooGateway: return config.zooGatewayModelId case providerIdentifiers.anthropic: diff --git a/packages/types/src/__tests__/nanogpt.test.ts b/packages/types/src/__tests__/nanogpt.test.ts new file mode 100644 index 0000000000..50eacdd276 --- /dev/null +++ b/packages/types/src/__tests__/nanogpt.test.ts @@ -0,0 +1,67 @@ +import { + applyNanoGptRoutingPreference, + dynamicProviders, + getModelId, + getProviderDefaultModelId, + isSecretStateKey, + nanoGptDefaultModelId, + nanoGptDefaultRoutingPreference, + providerIdentifiers, + providerSettingsSchema, +} from "../index.js" + +describe("NanoGPT shared contract", () => { + it("registers the stable dynamic-provider identity and default model", () => { + expect(providerIdentifiers.nanogpt).toBe("nanogpt") + expect(dynamicProviders).toContain("nanogpt") + expect(getProviderDefaultModelId("nanogpt")).toBe(nanoGptDefaultModelId) + }) + + it("classifies the API key as secret and resolves missing routing to auto", () => { + expect(isSecretStateKey("nanoGptApiKey")).toBe(true) + const settings = providerSettingsSchema.parse({ apiProvider: "nanogpt", nanoGptModelId: "model" }) + expect(settings.nanoGptRoutingPreference ?? nanoGptDefaultRoutingPreference).toBe("auto") + expect(getModelId(settings)).toBe("model") + }) +}) + +describe("applyNanoGptRoutingPreference", () => { + it.each([ + ["auto", "model"], + ["fast", "model:fast"], + ["cheap", "model:cheap"], + ["latency", "model:latency"], + ["throughput", "model:throughput"], + ["tools", "model:tools"], + ["caching", "model"], + ] as const)("maps %s routing", (preference, expected) => { + expect(applyNanoGptRoutingPreference("model", preference)).toBe(expected) + }) + + it.each([ + "speed", + "fast", + "throughput", + "latency", + "price", + "cheap", + "floor", + "tools", + "caching", + "cache", + "cached", + ])("replaces the recognized %s routing alias", (alias) => { + expect(applyNanoGptRoutingPreference(`model:thinking:${alias}`, "cheap")).toBe("model:thinking:cheap") + expect(applyNanoGptRoutingPreference(`model:thinking:${alias}`, "auto")).toBe("model:thinking") + }) + + it("preserves legitimate identity suffixes", () => { + expect(applyNanoGptRoutingPreference("model:thinking", "fast")).toBe("model:thinking:fast") + expect(applyNanoGptRoutingPreference("model:thinking", "auto")).toBe("model:thinking") + }) + + it("normalizes multiple trailing routing suffixes to exactly one active preference", () => { + expect(applyNanoGptRoutingPreference("model:thinking:fast:cheap", "latency")).toBe("model:thinking:latency") + expect(applyNanoGptRoutingPreference("model:thinking:FAST:CACHED", "auto")).toBe("model:thinking") + }) +}) diff --git a/packages/types/src/__tests__/provider-identifiers.test.ts b/packages/types/src/__tests__/provider-identifiers.test.ts index 870ce77d78..29e55b31cf 100644 --- a/packages/types/src/__tests__/provider-identifiers.test.ts +++ b/packages/types/src/__tests__/provider-identifiers.test.ts @@ -32,6 +32,7 @@ const expectedProviderIdentifiers = [ "deepseek", "opencode-go", "kenari", + "nanogpt", "ollama", "lmstudio", "vscode-lm", @@ -106,6 +107,7 @@ describe("provider identifiers", () => { providerIdentifiers.moonshot, providerIdentifiers.opencodeGo, providerIdentifiers.kenari, + providerIdentifiers.nanogpt, providerIdentifiers.kimiCode, ]) expect(localProviders).toEqual([providerIdentifiers.ollama, providerIdentifiers.lmstudio]) diff --git a/packages/types/src/__tests__/provider-model-id.test.ts b/packages/types/src/__tests__/provider-model-id.test.ts index bec5425b54..7c404c1cd4 100644 --- a/packages/types/src/__tests__/provider-model-id.test.ts +++ b/packages/types/src/__tests__/provider-model-id.test.ts @@ -13,6 +13,7 @@ const expectedModelIdKeys = [ "vercelAiGatewayModelId", "opencodeGoModelId", "kenariModelId", + "nanoGptModelId", "zooGatewayModelId", ] as const diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index dc3ea072fd..bd440512ce 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -324,6 +324,7 @@ export const SECRET_STATE_KEYS = [ "vercelAiGatewayApiKey", "opencodeGoApiKey", "kenariApiKey", + "nanoGptApiKey", "basetenApiKey", ] as const diff --git a/packages/types/src/provider-identifiers.ts b/packages/types/src/provider-identifiers.ts index f231bc1ab1..fdf507bb6b 100644 --- a/packages/types/src/provider-identifiers.ts +++ b/packages/types/src/provider-identifiers.ts @@ -14,6 +14,7 @@ export const providerIdentifiers = { deepseek: "deepseek", opencodeGo: "opencode-go", kenari: "kenari", + nanogpt: "nanogpt", ollama: "ollama", lmstudio: "lmstudio", vscodeLm: "vscode-lm", diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 240e34e152..4a432970c2 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -6,6 +6,10 @@ export { OPEN_AI_CODEX_SERVICE_TIER_KEY, kimiCodeAuthMethodSchema, type KimiCodeAuthMethod, + nanoGptDefaultRoutingPreference, + nanoGptRoutingPreferences, + nanoGptRoutingPreferenceSchema, + type NanoGptRoutingPreference, zaiApiLineSchema, type ZaiApiLine, } from "./provider-settings/index.js" @@ -67,6 +71,7 @@ export const dynamicProviders = [ providerIdentifiers.moonshot, providerIdentifiers.opencodeGo, providerIdentifiers.kenari, + providerIdentifiers.nanogpt, providerIdentifiers.kimiCode, ] as const @@ -280,6 +285,7 @@ export const modelIdKeys = [ "vercelAiGatewayModelId", "opencodeGoModelId", "kenariModelId", + "nanoGptModelId", "zooGatewayModelId", ] as const satisfies readonly ModelIdKey[] @@ -508,6 +514,7 @@ export const MODELS_BY_PROVIDER: Record< }, [providerIdentifiers.opencodeGo]: { id: providerIdentifiers.opencodeGo, label: "Opencode Go", models: [] }, [providerIdentifiers.kenari]: { id: providerIdentifiers.kenari, label: "Kenari", models: [] }, + [providerIdentifiers.nanogpt]: { id: providerIdentifiers.nanogpt, label: "NanoGPT", models: [] }, [providerIdentifiers.zooGateway]: { id: providerIdentifiers.zooGateway, label: "Zoo Gateway", models: [] }, // Local providers; models discovered from localhost endpoints. diff --git a/packages/types/src/provider-settings/index.ts b/packages/types/src/provider-settings/index.ts index e97fbf5995..225b54fc29 100644 --- a/packages/types/src/provider-settings/index.ts +++ b/packages/types/src/provider-settings/index.ts @@ -30,6 +30,7 @@ import { qwenCodeProviderDefinition } from "./qwen-code.js" import { vercelAiGatewayProviderDefinition } from "./vercel-ai-gateway.js" import { opencodeGoProviderDefinition } from "./opencode-go.js" import { kenariProviderDefinition } from "./kenari.js" +import { nanoGptProviderDefinition } from "./nanogpt.js" import { zooGatewayProviderDefinition } from "./zoo-gateway.js" import { basetenProviderDefinition } from "./baseten.js" @@ -38,6 +39,12 @@ import type { ProviderDefinition } from "./common.js" export { OPEN_AI_CODEX_SERVICE_TIER_KEY } from "./openai-codex.js" export { kimiCodeAuthMethodSchema, type KimiCodeAuthMethod } from "./kimi-code.js" export { zaiApiLineSchema, type ZaiApiLine } from "./zai.js" +export { + nanoGptDefaultRoutingPreference, + nanoGptRoutingPreferences, + nanoGptRoutingPreferenceSchema, + type NanoGptRoutingPreference, +} from "./nanogpt.js" export type { ProviderDefinition } from "./common.js" export const providerDefinitionList = [ @@ -73,6 +80,7 @@ export const providerDefinitionList = [ vercelAiGatewayProviderDefinition, opencodeGoProviderDefinition, kenariProviderDefinition, + nanoGptProviderDefinition, zooGatewayProviderDefinition, basetenProviderDefinition, ] as const satisfies readonly ProviderDefinition[] diff --git a/packages/types/src/provider-settings/nanogpt.ts b/packages/types/src/provider-settings/nanogpt.ts new file mode 100644 index 0000000000..e1db6a237f --- /dev/null +++ b/packages/types/src/provider-settings/nanogpt.ts @@ -0,0 +1,24 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { baseProviderSettingsShape, createModelIdAccessor, createProviderDefinition } from "./common.js" + +const NANOGPT_MODEL_ID_FIELD = "nanoGptModelId" + +export const nanoGptRoutingPreferences = ["auto", "fast", "cheap", "latency", "throughput", "tools", "caching"] as const + +export const nanoGptRoutingPreferenceSchema = z.enum(nanoGptRoutingPreferences) +export type NanoGptRoutingPreference = z.infer +export const nanoGptDefaultRoutingPreference: NanoGptRoutingPreference = "auto" + +export const nanoGptProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.nanogpt, + modelIdKey: NANOGPT_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(NANOGPT_MODEL_ID_FIELD), + schema: { + ...baseProviderSettingsShape, + nanoGptApiKey: z.string().optional(), + [NANOGPT_MODEL_ID_FIELD]: z.string().optional(), + nanoGptRoutingPreference: nanoGptRoutingPreferenceSchema.optional(), + }, +}) diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index 5f7d1afda4..b26476eaed 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -25,6 +25,7 @@ export * from "./xai.js" export * from "./vercel-ai-gateway.js" export * from "./opencode-go.js" export * from "./kenari.js" +export * from "./nanogpt.js" export * from "./kimi-code.js" export * from "./zai.js" export * from "./minimax.js" @@ -54,6 +55,7 @@ import { xaiDefaultModelId } from "./xai.js" import { vercelAiGatewayDefaultModelId } from "./vercel-ai-gateway.js" import { opencodeGoDefaultModelId } from "./opencode-go.js" import { kenariDefaultModelId } from "./kenari.js" +import { nanoGptDefaultModelId } from "./nanogpt.js" import { kimiCodeDefaultModelId } from "./kimi-code.js" import { internationalZAiDefaultModelId, mainlandZAiDefaultModelId } from "./zai.js" import { minimaxDefaultModelId } from "./minimax.js" @@ -133,6 +135,8 @@ export function getProviderDefaultModelId( return opencodeGoDefaultModelId case providerIdentifiers.kenari: return kenariDefaultModelId + case providerIdentifiers.nanogpt: + return nanoGptDefaultModelId case providerIdentifiers.kimiCode: return kimiCodeDefaultModelId case providerIdentifiers.zooGateway: diff --git a/packages/types/src/providers/nanogpt.ts b/packages/types/src/providers/nanogpt.ts new file mode 100644 index 0000000000..1b0459ab24 --- /dev/null +++ b/packages/types/src/providers/nanogpt.ts @@ -0,0 +1,57 @@ +import type { ModelInfo } from "../model.js" +import type { NanoGptRoutingPreference } from "../provider-settings/nanogpt.js" + +export const NANOGPT_BASE_URL = "https://nano-gpt.com/api/v1" + +export const nanoGptDefaultModelId = "openai/gpt-5.6-sol" + +export const nanoGptDefaultModelInfo: ModelInfo = { + maxTokens: 128_000, + contextWindow: 1_050_000, + supportsImages: true, + supportsPromptCache: false, + inputPrice: 5, + outputPrice: 30, + description: "NanoGPT model. Available models and metadata are resolved dynamically from the detailed catalog.", +} + +const ROUTING_SUFFIXES = new Set([ + "speed", + "fast", + "throughput", + "latency", + "price", + "cheap", + "floor", + "tools", + "caching", + "cache", + "cached", +]) + +const ROUTING_SUFFIX_BY_PREFERENCE: Record, string> = { + fast: "fast", + cheap: "cheap", + latency: "latency", + throughput: "throughput", + tools: "tools", +} + +/** Applies one request-only NanoGPT routing suffix while preserving identity suffixes such as `:thinking`. */ +export function applyNanoGptRoutingPreference(modelId: string, preference: NanoGptRoutingPreference = "auto"): string { + let canonicalId = modelId + let separatorIndex = canonicalId.lastIndexOf(":") + let finalSuffix = separatorIndex >= 0 ? canonicalId.slice(separatorIndex + 1).toLowerCase() : "" + + // Normalize every trailing routing alias. This protects request identity when a + // previously-routed ID is routed again and prevents multiple active suffixes. + while (separatorIndex >= 0 && ROUTING_SUFFIXES.has(finalSuffix)) { + canonicalId = canonicalId.slice(0, separatorIndex) + separatorIndex = canonicalId.lastIndexOf(":") + finalSuffix = separatorIndex >= 0 ? canonicalId.slice(separatorIndex + 1).toLowerCase() : "" + } + + return preference === "auto" || preference === "caching" + ? canonicalId + : `${canonicalId}:${ROUTING_SUFFIX_BY_PREFERENCE[preference]}` +} diff --git a/src/api/__tests__/index.spec.ts b/src/api/__tests__/index.spec.ts index 3617c3cd6d..2fe940f53a 100644 --- a/src/api/__tests__/index.spec.ts +++ b/src/api/__tests__/index.spec.ts @@ -47,6 +47,7 @@ import { MimoHandler, MistralHandler, MoonshotHandler, + NanoGptHandler, OpenAiCodexHandler, OpenAiHandler, OpenAiNativeHandler, @@ -99,6 +100,7 @@ const expectedHandlers = { [providerIdentifiers.vercelAiGateway]: VercelAiGatewayHandler, [providerIdentifiers.opencodeGo]: OpencodeGoHandler, [providerIdentifiers.kenari]: KenariHandler, + [providerIdentifiers.nanogpt]: NanoGptHandler, [providerIdentifiers.zooGateway]: ZooGatewayHandler, [providerIdentifiers.minimax]: MiniMaxHandler, [providerIdentifiers.baseten]: BasetenHandler, diff --git a/src/api/index.ts b/src/api/index.ts index f48ab50c0e..8e7f20d66f 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -42,6 +42,7 @@ import { VercelAiGatewayHandler, OpencodeGoHandler, KenariHandler, + NanoGptHandler, ZooGatewayHandler, MiniMaxHandler, MimoHandler, @@ -224,6 +225,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { return new OpencodeGoHandler(options) case providerIdentifiers.kenari: return new KenariHandler(options) + case providerIdentifiers.nanogpt: + return new NanoGptHandler(options) case providerIdentifiers.zooGateway: return new ZooGatewayHandler(options) case providerIdentifiers.minimax: diff --git a/src/api/providers/__tests__/nanogpt.spec.ts b/src/api/providers/__tests__/nanogpt.spec.ts new file mode 100644 index 0000000000..e58ee873db --- /dev/null +++ b/src/api/providers/__tests__/nanogpt.spec.ts @@ -0,0 +1,293 @@ +vi.mock("vscode", () => ({ + workspace: { getConfiguration: () => ({ get: (_key: string, defaultValue?: unknown) => defaultValue }) }, +})) + +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" + +import { nanoGptDefaultModelId } from "@roo-code/types" + +import { buildApiHandler } from "../../index" +import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" +import { NanoGptHandler } from "../nanogpt" +import { getModels } from "../fetchers/modelCache" + +vi.mock("openai") +vi.mock("../fetchers/modelCache", () => ({ + getModels: vi.fn().mockResolvedValue({ + "model:thinking": { + maxTokens: 128000, + contextWindow: 1050000, + supportsImages: true, + supportsPromptCache: false, + supportsReasoningEffort: ["low", "medium", "high"], + }, + }), + getModelsFromCache: vi.fn(), +})) + +const mockCreate = vi.fn() +vi.mocked(OpenAI).mockImplementation(function () { + return { chat: { completions: { create: mockCreate } } } as unknown as OpenAI +}) + +const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] + +describe("NanoGptHandler", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(getModels).mockResolvedValue({ + "model:thinking": { + maxTokens: 128000, + contextWindow: 1050000, + supportsImages: true, + supportsPromptCache: false, + supportsReasoningEffort: ["low", "medium", "high"], + }, + }) + mockCreate.mockResolvedValue(asyncStreamFrom([])) + }) + + it("is constructed by the backend provider registry", () => { + expect(buildApiHandler({ apiProvider: "nanogpt" })).toBeInstanceOf(NanoGptHandler) + }) + + it("keeps the canonical model ID while applying request-only routing", async () => { + const handler = new NanoGptHandler({ nanoGptModelId: "model:thinking", nanoGptRoutingPreference: "fast" }) + await collectStream(handler.createMessage("system", messages)) + expect(handler.getModel().id).toBe("model:thinking") + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: "model:thinking:fast" }), + expect.anything(), + ) + }) + + it.each([ + ["auto", "model:thinking"], + ["fast", "model:thinking:fast"], + ["cheap", "model:thinking:cheap"], + ["latency", "model:thinking:latency"], + ["throughput", "model:thinking:throughput"], + ["tools", "model:thinking:tools"], + ] as const)("sends %s routing", async (preference, expected) => { + const handler = new NanoGptHandler({ nanoGptModelId: "model:thinking", nanoGptRoutingPreference: preference }) + await collectStream(handler.createMessage("system", messages)) + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: expected }), expect.anything()) + }) + + it("requests cache-capable routing without changing the streaming model ID", async () => { + const handler = new NanoGptHandler({ + nanoGptModelId: "model:thinking", + nanoGptRoutingPreference: "caching", + }) + await collectStream(handler.createMessage("system", messages)) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: "model:thinking", caching: true, stream: true }), + expect.anything(), + ) + }) + + it("streams interleaved text, both reasoning variants, and parallel tool calls", async () => { + mockCreate.mockResolvedValue( + asyncStreamFrom([ + { choices: [{ delta: { content: "answer", reasoning: "modern" } }] }, + { choices: [{ delta: { reasoning_content: "legacy" } }] }, + { + choices: [ + { + delta: { + tool_calls: [ + { index: 0, id: "call-1", function: { name: "read_file", arguments: '{"path":' } }, + { + index: 1, + id: "call-2", + function: { name: "search_files", arguments: '{"query":' }, + }, + ], + }, + }, + ], + }, + ]), + ) + const chunks = await collectStream( + new NanoGptHandler({ nanoGptModelId: "model:thinking" }).createMessage("sys", messages), + ) + expect(chunks).toEqual([ + { type: "text", text: "answer" }, + { type: "reasoning", text: "modern" }, + { type: "reasoning", text: "legacy" }, + { type: "tool_call_partial", index: 0, id: "call-1", name: "read_file", arguments: '{"path":' }, + { type: "tool_call_partial", index: 1, id: "call-2", name: "search_files", arguments: '{"query":' }, + ]) + }) + + it("forwards native tools, choices, usage streaming, max_tokens, reasoning effort, and cancellation", async () => { + const signal = new AbortController().signal + const tools: OpenAI.Chat.ChatCompletionTool[] = [ + { type: "function", function: { name: "read_file", description: "Read", parameters: { type: "object" } } }, + ] + const handler = new NanoGptHandler({ + nanoGptModelId: "model:thinking", + modelTemperature: 0.7, + reasoningEffort: "high", + }) + await collectStream( + handler.createMessage("sys", messages, { + taskId: "task", + tools, + tool_choice: "required", + parallelToolCalls: false, + abortSignal: signal, + }), + ) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + stream: true, + stream_options: { include_usage: true }, + max_tokens: 128000, + temperature: 0.7, + reasoning_effort: "high", + tools: [ + expect.objectContaining({ + type: "function", + function: expect.objectContaining({ name: "read_file", description: "Read" }), + }), + ], + tool_choice: "required", + parallel_tool_calls: false, + }), + { signal }, + ) + expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("max_completion_tokens") + }) + + it("omits temperature when it was not explicitly configured", async () => { + await collectStream(new NanoGptHandler({ nanoGptModelId: "model:thinking" }).createMessage("sys", messages)) + expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("temperature") + }) + + it("keeps unauthenticated catalog fetches public and redacts streaming errors", async () => { + mockCreate.mockRejectedValue(new Error("upstream rejected secret-key")) + const handler = new NanoGptHandler({ nanoGptApiKey: "secret-key", nanoGptModelId: "model:thinking" }) + await expect(collectStream(handler.createMessage("sys", messages))).rejects.toThrow( + "NanoGPT streaming error: upstream rejected [REDACTED]", + ) + + vi.mocked(getModels).mockResolvedValue({}) + mockCreate.mockResolvedValue(asyncStreamFrom([])) + await collectStream(new NanoGptHandler({ nanoGptModelId: "model:thinking" }).createMessage("sys", messages)) + expect(getModels).toHaveBeenLastCalledWith(expect.objectContaining({ provider: "nanogpt", apiKey: undefined })) + }) + + it("maps usage with root-field precedence and no reasoning double count", async () => { + mockCreate.mockResolvedValue( + asyncStreamFrom([ + { + choices: [], + usage: { + prompt_tokens: 20, + completion_tokens: 10, + cache_read_input_tokens: 7, + cache_creation_input_tokens: 3, + prompt_tokens_details: { cached_tokens: 5 }, + completion_tokens_details: { reasoning_tokens: 4 }, + reasoning_tokens: 2, + }, + }, + ]), + ) + expect( + await collectStream( + new NanoGptHandler({ nanoGptModelId: "model:thinking" }).createMessage("sys", messages), + ), + ).toEqual([ + { + type: "usage", + inputTokens: 20, + outputTokens: 10, + cacheReadTokens: 7, + cacheWriteTokens: 3, + reasoningTokens: 4, + }, + ]) + }) + + it("falls back to nested cache reads and root reasoning tokens", async () => { + mockCreate.mockResolvedValue( + asyncStreamFrom([ + { + choices: [], + usage: { + prompt_tokens: 2, + completion_tokens: 1, + prompt_tokens_details: { cached_tokens: 1 }, + reasoning_tokens: 1, + }, + }, + ]), + ) + expect( + await collectStream( + new NanoGptHandler({ nanoGptModelId: "model:thinking" }).createMessage("sys", messages), + ), + ).toEqual([ + { + type: "usage", + inputTokens: 2, + outputTokens: 1, + cacheReadTokens: 1, + cacheWriteTokens: undefined, + reasoningTokens: 1, + }, + ]) + }) + + describe("completePrompt", () => { + it("requests cache-capable routing without changing the completion model ID", async () => { + mockCreate.mockResolvedValue({ choices: [{ message: { content: "response" } }] }) + const handler = new NanoGptHandler({ + nanoGptModelId: "model:thinking", + nanoGptRoutingPreference: "caching", + }) + await handler.completePrompt("prompt") + expect(mockCreate.mock.calls[0][0]).toMatchObject({ + model: "model:thinking", + caching: true, + stream: false, + }) + }) + + it("returns normal and empty content", async () => { + mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] }) + const handler = new NanoGptHandler({ nanoGptModelId: "model:thinking" }) + expect(await handler.completePrompt("prompt")).toBe("response") + expect(mockCreate.mock.calls[0][0]).toMatchObject({ + model: "model:thinking", + stream: false, + max_tokens: 128000, + }) + + mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: null } }] }) + expect(await handler.completePrompt("prompt")).toBe("") + }) + + it("wraps useful upstream errors without leaking the API key", async () => { + mockCreate.mockRejectedValue(new Error("upstream rejected secret-key")) + const handler = new NanoGptHandler({ nanoGptApiKey: "secret-key", nanoGptModelId: "model:thinking" }) + await expect(handler.completePrompt("prompt")).rejects.toThrow( + "NanoGPT completion error: upstream rejected [REDACTED]", + ) + }) + }) + + it("uses the documented fallback model", async () => { + vi.mocked(getModels).mockResolvedValue({}) + const handler = new NanoGptHandler({}) + await collectStream(handler.createMessage("sys", messages)) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: nanoGptDefaultModelId }), + expect.anything(), + ) + }) +}) diff --git a/src/api/providers/fetchers/__tests__/modelCache.spec.ts b/src/api/providers/fetchers/__tests__/modelCache.spec.ts index 512cbdb9c6..108aa1827b 100644 --- a/src/api/providers/fetchers/__tests__/modelCache.spec.ts +++ b/src/api/providers/fetchers/__tests__/modelCache.spec.ts @@ -45,6 +45,7 @@ vi.mock("../litellm") vi.mock("../openrouter") vi.mock("../requesty") vi.mock("../kenari") +vi.mock("../nanogpt") vi.mock("../moonshot") vi.mock("../zoo-gateway") @@ -70,6 +71,7 @@ import { getLiteLLMModels } from "../litellm" import { getOpenRouterModels } from "../openrouter" import { getRequestyModels } from "../requesty" import { getKenariModels } from "../kenari" +import { getNanoGptModels } from "../nanogpt" import { getMoonshotModels } from "../moonshot" import { getZooGatewayModels } from "../zoo-gateway" @@ -77,6 +79,7 @@ const mockGetLiteLLMModels = getLiteLLMModels as Mock const mockGetOpenRouterModels = getOpenRouterModels as Mock const mockGetRequestyModels = getRequestyModels as Mock const mockGetKenariModels = getKenariModels as Mock +const mockGetNanoGptModels = getNanoGptModels as Mock const mockGetMoonshotModels = getMoonshotModels as Mock const mockGetZooGatewayModels = getZooGatewayModels as Mock @@ -197,6 +200,22 @@ describe("getModels with new GetModelsOptions", () => { expect(result).toEqual(mockModels) }) + it("dispatches NanoGPT with an optional API key", async () => { + const mockModels = { + "openai/gpt-5.6-sol": { + maxTokens: 128000, + contextWindow: 1050000, + supportsPromptCache: false, + }, + } + mockGetNanoGptModels.mockResolvedValue(mockModels) + + const result = await getModels({ provider: providerIdentifiers.nanogpt, apiKey: "nanogpt-key" }) + + expect(mockGetNanoGptModels).toHaveBeenCalledWith("nanogpt-key") + expect(result).toEqual(mockModels) + }) + it("handles errors and re-throws them", async () => { const expectedError = new Error("LiteLLM connection failed") mockGetLiteLLMModels.mockRejectedValue(expectedError) @@ -1079,6 +1098,31 @@ describe("key-scoped cache key derivation", () => { }) }) +describe("NanoGPT key-scoped cache isolation", () => { + const nanoGptModels = { + "openai/gpt-5.6-sol": { maxTokens: 128000, contextWindow: 1050000, supportsPromptCache: false }, + } + + beforeEach(() => { + vi.clearAllMocks() + mockGetNanoGptModels.mockResolvedValue(nanoGptModels) + }) + + it("separates public, key A, and key B cache identities without exposing raw keys", async () => { + const mockCache = vi.mocked(new (vi.mocked(NodeCache))()) + mockCache.get.mockReturnValue(undefined) + + await getModels({ provider: providerIdentifiers.nanogpt }) + await getModels({ provider: providerIdentifiers.nanogpt, apiKey: "nano-key-a" }) + await getModels({ provider: providerIdentifiers.nanogpt, apiKey: "nano-key-b" }) + + const cacheKeys = mockCache.set.mock.calls.map(([key]) => key as string) + expect(new Set(cacheKeys).size).toBe(3) + expect(cacheKeys).toContain("nanogpt") + expect(cacheKeys.every((key) => !key.includes("nano-key-a") && !key.includes("nano-key-b"))).toBe(true) + }) +}) + describe("compound cache key derivation across scoping dimensions", () => { // Exercises every branch of getCacheKey via the public getModels() entry point. // litellm is url-scoped AND key-scoped; openrouter is neither, so it hits the bare diff --git a/src/api/providers/fetchers/__tests__/nanogpt.spec.ts b/src/api/providers/fetchers/__tests__/nanogpt.spec.ts new file mode 100644 index 0000000000..9e96e1808f --- /dev/null +++ b/src/api/providers/fetchers/__tests__/nanogpt.spec.ts @@ -0,0 +1,169 @@ +import axios from "axios" + +import { NANOGPT_BASE_URL, nanoGptDefaultModelInfo } from "@roo-code/types" + +import { getNanoGptModels, parseNanoGptModel } from "../nanogpt" + +vi.mock("axios") + +describe("NanoGPT model fetcher", () => { + beforeEach(() => vi.clearAllMocks()) + + it("requests the detailed catalog with optional Bearer authorization", async () => { + vi.mocked(axios.get).mockResolvedValue({ data: { data: [] } }) + await getNanoGptModels("key-a") + expect(axios.get).toHaveBeenCalledWith(`${NANOGPT_BASE_URL}/models?detailed=true`, { + headers: { Authorization: "Bearer key-a" }, + timeout: 10_000, + }) + }) + + it("supports unauthenticated catalog requests", async () => { + vi.mocked(axios.get).mockResolvedValue({ data: { data: [] } }) + await getNanoGptModels() + expect(axios.get).toHaveBeenCalledWith(`${NANOGPT_BASE_URL}/models?detailed=true`, { + headers: undefined, + timeout: 10_000, + }) + }) + + it("maps detailed metadata and exact per-million pricing for multiple models", async () => { + vi.mocked(axios.get).mockResolvedValue({ + data: { + unknown_top_level: true, + data: [ + { + id: "vision-model", + name: "Vision Model", + description: "Detailed description", + context_length: 1_050_000, + max_output_tokens: 128_000, + capabilities: { vision: true, tool_calling: true, unknown: "allowed" }, + pricing: { + prompt: 2.5, + completion: 10, + cacheReadInputPer1kTokens: 0.001, + cacheWriteInputPer1kTokens: 0.002, + unknown: 1, + }, + unknown: "allowed", + }, + { id: "text-model", capabilities: { vision: false } }, + ], + }, + }) + + const models = await getNanoGptModels() + expect(Object.keys(models)).toEqual(["vision-model", "text-model"]) + expect(models["vision-model"]).toEqual({ + contextWindow: 1_050_000, + maxTokens: 128_000, + supportsPromptCache: false, + supportsImages: true, + displayName: "Vision Model", + description: "Detailed description", + inputPrice: 2.5, + outputPrice: 10, + cacheReadsPrice: 1, + cacheWritesPrice: 2, + }) + expect(models["text-model"].supportsImages).toBe(false) + }) + + it("skips malformed records and models explicitly lacking tool calling", async () => { + vi.mocked(axios.get).mockResolvedValue({ + data: { + data: [ + { id: "eligible" }, + { missing: "id" }, + { id: "chat-only", capabilities: { tool_calling: false } }, + ], + }, + }) + const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined) + expect(await getNanoGptModels()).toEqual({ + eligible: { + contextWindow: nanoGptDefaultModelInfo.contextWindow, + maxTokens: nanoGptDefaultModelInfo.maxTokens, + supportsPromptCache: false, + }, + }) + expect(warning).toHaveBeenCalledOnce() + warning.mockRestore() + }) + + it("keeps models with null token metadata and preserves reasoning capability", async () => { + vi.mocked(axios.get).mockResolvedValue({ + data: { + data: [ + { + id: "reasoning-model", + context_length: null, + max_output_tokens: null, + capabilities: { reasoning: true }, + }, + { id: "non-reasoning-model", capabilities: { reasoning: false } }, + ], + }, + }) + + const models = await getNanoGptModels() + expect(models["reasoning-model"]).toEqual({ + contextWindow: nanoGptDefaultModelInfo.contextWindow, + maxTokens: nanoGptDefaultModelInfo.maxTokens, + supportsPromptCache: false, + supportsReasoningEffort: ["low", "medium", "high"], + }) + expect(models["non-reasoning-model"].supportsReasoningEffort).toBe(false) + }) + + it.each([{ data: null }, [], null])("returns no models for invalid top-level data %#", async (data) => { + vi.mocked(axios.get).mockResolvedValue({ data }) + const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined) + expect(await getNanoGptModels()).toEqual({}) + warning.mockRestore() + }) + + it.each([new Error("network unavailable"), "network unavailable"])( + "returns no models on network failure", + async (error) => { + vi.mocked(axios.get).mockRejectedValue(error) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined) + expect(await getNanoGptModels()).toEqual({}) + consoleError.mockRestore() + }, + ) + + it("rejects negative numeric metadata without leaking the API key in errors", async () => { + vi.mocked(axios.get) + .mockResolvedValueOnce({ + data: { + data: [ + { id: "valid-free", pricing: { prompt: 0, completion: 0 } }, + { id: "invalid-price", pricing: { prompt: -1 } }, + ], + }, + }) + .mockRejectedValueOnce(new Error("upstream rejected secret-key")) + const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined) + + expect(await getNanoGptModels("secret-key")).toEqual({ + "valid-free": expect.objectContaining({ inputPrice: 0, outputPrice: 0 }), + }) + expect(await getNanoGptModels("secret-key")).toEqual({}) + expect(consoleError).toHaveBeenLastCalledWith("Error fetching NanoGPT models: upstream rejected [REDACTED]") + + warning.mockRestore() + consoleError.mockRestore() + }) + + it("does not invent absent optional metadata", () => { + const info = parseNanoGptModel({ id: "minimal" }) + expect(info).toEqual({ + contextWindow: nanoGptDefaultModelInfo.contextWindow, + maxTokens: nanoGptDefaultModelInfo.maxTokens, + supportsPromptCache: false, + }) + }) +}) diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 6ef68864c1..50dbe12f6e 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -21,6 +21,7 @@ import { getOpenRouterModels } from "./openrouter" import { getVercelAiGatewayModels } from "./vercel-ai-gateway" import { getOpencodeGoModels } from "./opencode-go" import { getKenariModels } from "./kenari" +import { getNanoGptModels } from "./nanogpt" import { getRequestyModels } from "./requesty" import { getUnboundModels } from "./unbound" import { getLiteLLMModels } from "./litellm" @@ -95,6 +96,7 @@ const KEY_SCOPED_PROVIDERS: ReadonlySet = new Set([ providerIdentifiers.moonshot, // Per-key model visibility (api.moonshot.ai vs api.moonshot.cn) providerIdentifiers.zooGateway, // Per-session-token account identity providerIdentifiers.kimiCode, // Per-session-token account identity + providerIdentifiers.nanogpt, // Public catalog can still vary by API-key allowlist ]) // Providers whose model lists are scoped to the signed-in user (e.g. per-account @@ -253,6 +255,9 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise { provider: providerIdentifiers.vercelAiGateway, options: { provider: providerIdentifiers.vercelAiGateway }, }, + { + provider: providerIdentifiers.nanogpt, + options: { provider: providerIdentifiers.nanogpt }, + }, ] // Refresh each provider in background (fire and forget) diff --git a/src/api/providers/fetchers/nanogpt.ts b/src/api/providers/fetchers/nanogpt.ts new file mode 100644 index 0000000000..f0de1a4dd7 --- /dev/null +++ b/src/api/providers/fetchers/nanogpt.ts @@ -0,0 +1,97 @@ +import axios from "axios" +import { z } from "zod" + +import { NANOGPT_BASE_URL, nanoGptDefaultModelInfo, type ModelInfo, type ModelRecord } from "@roo-code/types" + +const nanoGptReasoningEfforts: NonNullable = ["low", "medium", "high"] + +const nanoGptPricingSchema = z.object({ + prompt: z.number().nonnegative().optional(), + completion: z.number().nonnegative().optional(), + cacheReadInputPer1kTokens: z.number().nonnegative().optional(), + cacheWriteInputPer1kTokens: z.number().nonnegative().optional(), +}) + +const nanoGptModelSchema = z.object({ + id: z.string().min(1), + name: z.string().optional(), + description: z.string().optional(), + context_length: z.number().positive().nullish(), + max_output_tokens: z.number().positive().nullish(), + capabilities: z + .object({ + vision: z.boolean().optional(), + tool_calling: z.boolean().optional(), + reasoning: z.boolean().optional(), + }) + .optional(), + pricing: nanoGptPricingSchema.optional(), +}) + +export type NanoGptModel = z.infer + +function getSafeErrorMessage(error: unknown, apiKey?: string): string { + const message = error instanceof Error ? error.message : String(error) + return apiKey ? message.replaceAll(apiKey, "[REDACTED]") : message +} + +const nanoGptModelsResponseSchema = z.object({ + data: z.array(z.unknown()), +}) + +export const parseNanoGptModel = (model: NanoGptModel): ModelInfo => ({ + contextWindow: model.context_length ?? nanoGptDefaultModelInfo.contextWindow, + maxTokens: model.max_output_tokens ?? nanoGptDefaultModelInfo.maxTokens, + supportsPromptCache: false, + ...(model.capabilities?.vision !== undefined ? { supportsImages: model.capabilities.vision } : {}), + ...(model.capabilities?.reasoning !== undefined + ? { supportsReasoningEffort: model.capabilities.reasoning ? [...nanoGptReasoningEfforts] : false } + : {}), + ...(model.name !== undefined ? { displayName: model.name } : {}), + ...(model.description !== undefined ? { description: model.description } : {}), + ...(model.pricing?.prompt !== undefined ? { inputPrice: model.pricing.prompt } : {}), + ...(model.pricing?.completion !== undefined ? { outputPrice: model.pricing.completion } : {}), + ...(model.pricing?.cacheReadInputPer1kTokens !== undefined + ? { cacheReadsPrice: model.pricing.cacheReadInputPer1kTokens * 1_000 } + : {}), + ...(model.pricing?.cacheWriteInputPer1kTokens !== undefined + ? { cacheWritesPrice: model.pricing.cacheWriteInputPer1kTokens * 1_000 } + : {}), +}) + +/** Fetches NanoGPT's public detailed catalog, optionally scoped by a Bearer key. */ +export async function getNanoGptModels(apiKey?: string): Promise { + try { + const response = await axios.get(`${NANOGPT_BASE_URL}/models?detailed=true`, { + headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined, + timeout: 10_000, + }) + const responseResult = nanoGptModelsResponseSchema.safeParse(response.data) + if (!responseResult.success) { + console.warn("NanoGPT models response did not match the expected top-level schema") + return {} + } + + const models: ModelRecord = {} + for (const rawModel of responseResult.data.data) { + const modelResult = nanoGptModelSchema.safeParse(rawModel) + if (!modelResult.success) { + console.warn("Skipping invalid NanoGPT model entry") + continue + } + + // NanoGPT can route to non-agentic models. An explicit false is authoritative; + // an omitted capability remains unknown and therefore eligible. + if (modelResult.data.capabilities?.tool_calling === false) { + continue + } + + models[modelResult.data.id] = parseNanoGptModel(modelResult.data) + } + + return models + } catch (error) { + console.error(`Error fetching NanoGPT models: ${getSafeErrorMessage(error, apiKey)}`) + return {} + } +} diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index 5bdd7c8deb..37fea799a5 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -29,6 +29,7 @@ export { FriendliHandler } from "./friendli" export { VercelAiGatewayHandler } from "./vercel-ai-gateway" export { OpencodeGoHandler } from "./opencode-go" export { KenariHandler } from "./kenari" +export { NanoGptHandler } from "./nanogpt" export { ZooGatewayHandler } from "./zoo-gateway" export { MiniMaxHandler } from "./minimax" export { MimoHandler } from "./mimo" diff --git a/src/api/providers/nanogpt.ts b/src/api/providers/nanogpt.ts new file mode 100644 index 0000000000..6ae0123043 --- /dev/null +++ b/src/api/providers/nanogpt.ts @@ -0,0 +1,172 @@ +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" + +import { + applyNanoGptRoutingPreference, + NANOGPT_BASE_URL, + nanoGptDefaultModelId, + nanoGptDefaultModelInfo, + type NanoGptRoutingPreference, +} from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../shared/api" + +import type { ApiStream, ApiStreamUsageChunk } from "../transform/stream" +import { convertToOpenAiMessages } from "../transform/openai-format" +import type { ApiHandlerCreateMessageMetadata, CompletePromptOptions, SingleCompletionHandler } from "../index" +import { RouterProvider } from "./router-provider" +import { extractReasoningFromDelta } from "./utils/extract-reasoning" + +type NanoGptUsage = OpenAI.CompletionUsage & { + cache_read_input_tokens?: number + cache_creation_input_tokens?: number + reasoning_tokens?: number +} + +type NanoGptCachingRequest = { caching?: true } + +const OPENAI_REASONING_EFFORTS = ["low", "medium", "high"] as const +type OpenAiReasoningEffort = (typeof OPENAI_REASONING_EFFORTS)[number] + +function getReasoningEffort(options: ApiHandlerOptions, supported: unknown): OpenAiReasoningEffort | undefined { + const effort = options.reasoningEffort + const selectedEffort = OPENAI_REASONING_EFFORTS.find((candidate) => candidate === effort) + if (!selectedEffort) { + return undefined + } + + if (supported === true || (Array.isArray(supported) && supported.includes(selectedEffort))) { + return selectedEffort + } + + return undefined +} + +function mapNanoGptUsage(usage: NanoGptUsage): ApiStreamUsageChunk { + return { + type: "usage", + inputTokens: usage.prompt_tokens ?? 0, + outputTokens: usage.completion_tokens ?? 0, + cacheReadTokens: usage.cache_read_input_tokens ?? usage.prompt_tokens_details?.cached_tokens, + cacheWriteTokens: usage.cache_creation_input_tokens, + reasoningTokens: usage.completion_tokens_details?.reasoning_tokens ?? usage.reasoning_tokens, + } +} + +export class NanoGptHandler extends RouterProvider implements SingleCompletionHandler { + constructor(options: ApiHandlerOptions) { + super({ + options, + name: "nanogpt", + baseURL: NANOGPT_BASE_URL, + apiKey: options.nanoGptApiKey, + modelId: options.nanoGptModelId, + defaultModelId: nanoGptDefaultModelId, + defaultModelInfo: nanoGptDefaultModelInfo, + }) + } + + private getRequestModelId(canonicalModelId: string): string { + return applyNanoGptRoutingPreference( + canonicalModelId, + this.options.nanoGptRoutingPreference as NanoGptRoutingPreference | undefined, + ) + } + + private createSafeError(operation: string, error: unknown): Error { + const message = error instanceof Error ? error.message : String(error) + const safeMessage = this.options.nanoGptApiKey + ? message.replaceAll(this.options.nanoGptApiKey, "[REDACTED]") + : message + return new Error(`NanoGPT ${operation} error: ${safeMessage}`) + } + + override async *createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + const { id: canonicalModelId, info } = await this.fetchModel() + const body: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming & NanoGptCachingRequest = { + model: this.getRequestModelId(canonicalModelId), + messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], + stream: true, + stream_options: { include_usage: true }, + max_tokens: info.maxTokens ?? undefined, + tools: this.convertToolsForOpenAI(metadata?.tools), + tool_choice: metadata?.tool_choice, + parallel_tool_calls: metadata?.parallelToolCalls ?? true, + ...(this.options.nanoGptRoutingPreference === "caching" ? { caching: true } : {}), + } + + if (this.options.modelTemperature !== undefined && this.supportsTemperature(canonicalModelId)) { + body.temperature = this.options.modelTemperature + } + + const reasoningEffort = getReasoningEffort(this.options, info.supportsReasoningEffort) + if (reasoningEffort) { + body.reasoning_effort = reasoningEffort + } + + try { + const completion = await this.client.chat.completions.create(body, { signal: metadata?.abortSignal }) + for await (const chunk of completion) { + const delta = chunk.choices[0]?.delta + if (delta?.content) { + yield { type: "text", text: delta.content } + } + + const reasoning = extractReasoningFromDelta(delta) + if (reasoning) { + yield { type: "reasoning", text: reasoning } + } + + for (const toolCall of delta?.tool_calls ?? []) { + yield { + type: "tool_call_partial", + index: toolCall.index, + id: toolCall.id, + name: toolCall.function?.name, + arguments: toolCall.function?.arguments, + } + } + + if (chunk.usage) { + yield mapNanoGptUsage(chunk.usage as NanoGptUsage) + } + } + } catch (error) { + throw this.createSafeError("streaming", error) + } + } + + async completePrompt(prompt: string, options?: CompletePromptOptions): Promise { + const { id: canonicalModelId, info } = await this.fetchModel() + const body: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming & NanoGptCachingRequest = { + model: this.getRequestModelId(canonicalModelId), + messages: [{ role: "user", content: prompt }], + stream: false, + max_tokens: info.maxTokens ?? undefined, + ...(this.options.nanoGptRoutingPreference === "caching" ? { caching: true } : {}), + } + + if (this.options.modelTemperature !== undefined && this.supportsTemperature(canonicalModelId)) { + body.temperature = this.options.modelTemperature + } + + const reasoningEffort = getReasoningEffort(this.options, info.supportsReasoningEffort) + if (reasoningEffort) { + body.reasoning_effort = reasoningEffort + } + + try { + const response = await this.client.chat.completions.create(body, { + signal: options?.abortSignal, + timeout: options?.timeoutMs, + }) + return response.choices[0]?.message.content ?? "" + } catch (error) { + throw this.createSafeError("completion", error) + } + } +} diff --git a/src/api/providers/router-provider.ts b/src/api/providers/router-provider.ts index cbdd49e58b..9e29be7bda 100644 --- a/src/api/providers/router-provider.ts +++ b/src/api/providers/router-provider.ts @@ -26,17 +26,10 @@ export abstract class RouterProvider extends BaseProvider { protected readonly modelId?: string protected readonly defaultModelId: string protected readonly defaultModelInfo: ModelInfo + protected readonly apiKey?: string protected readonly client: OpenAI - constructor({ - options, - name, - baseURL, - apiKey = "not-provided", - modelId, - defaultModelId, - defaultModelInfo, - }: RouterProviderOptions) { + constructor({ options, name, baseURL, apiKey, modelId, defaultModelId, defaultModelInfo }: RouterProviderOptions) { super() this.options = options @@ -44,10 +37,11 @@ export abstract class RouterProvider extends BaseProvider { this.modelId = modelId this.defaultModelId = defaultModelId this.defaultModelInfo = defaultModelInfo + this.apiKey = apiKey this.client = new OpenAI({ baseURL, - apiKey, + apiKey: apiKey ?? "not-provided", defaultHeaders: { ...DEFAULT_HEADERS, ...(options.openAiHeaders || {}), @@ -66,7 +60,7 @@ export abstract class RouterProvider extends BaseProvider { if (!this.modelFetchPromise) { this.modelFetchPromise = getModels({ provider: this.name, - apiKey: this.client.apiKey, + apiKey: this.apiKey, baseUrl: this.client.baseURL, }) .then((models) => { @@ -105,7 +99,7 @@ export abstract class RouterProvider extends BaseProvider { const cachedModels = getModelsFromCache({ provider: this.name, baseUrl: this.client.baseURL, - apiKey: this.client.apiKey, + apiKey: this.apiKey, }) if (cachedModels?.[id]) { // Also populate instance models for future calls diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index 551591b0ed..d4cae48177 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -308,6 +308,25 @@ describe("ContextProxy", () => { }) describe("setProviderSettings", () => { + it("stores and returns the complete NanoGPT configuration across secret and global state", async () => { + await proxy.setProviderSettings({ + apiProvider: "nanogpt", + nanoGptApiKey: "nanogpt-secret", + nanoGptModelId: "openai/model", + nanoGptRoutingPreference: "throughput", + }) + + expect(mockSecrets.store).toHaveBeenCalledWith("nanoGptApiKey", "nanogpt-secret") + expect(mockGlobalState.update).toHaveBeenCalledWith("nanoGptModelId", "openai/model") + expect(mockGlobalState.update).toHaveBeenCalledWith("nanoGptRoutingPreference", "throughput") + expect(proxy.getProviderSettings()).toMatchObject({ + apiProvider: "nanogpt", + nanoGptApiKey: "nanogpt-secret", + nanoGptModelId: "openai/model", + nanoGptRoutingPreference: "throughput", + }) + }) + it("should clear old API configuration values and set new ones", async () => { // Set up initial API configuration values await proxy.updateGlobalState("apiModelId", "old-model") diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 00f848bec4..6167158ccc 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1011,6 +1011,28 @@ describe("ClineProvider", () => { expect(state).toHaveProperty("writeDelayMs") }) + test("getState and getStateToPostToWebview return the complete NanoGPT configuration", async () => { + await provider.resolveWebviewView(mockWebviewView) + await provider.contextProxy.setProviderSettings({ + apiProvider: "nanogpt", + nanoGptApiKey: "nanogpt-secret", + nanoGptModelId: "openai/model", + nanoGptRoutingPreference: "latency", + }) + + const state = await provider.getState() + const postedState = await provider.getStateToPostToWebview() + const expectedConfiguration = { + apiProvider: "nanogpt", + nanoGptApiKey: "nanogpt-secret", + nanoGptModelId: "openai/model", + nanoGptRoutingPreference: "latency", + } + + expect(state.apiConfiguration).toMatchObject(expectedConfiguration) + expect(postedState.apiConfiguration).toMatchObject(expectedConfiguration) + }) + test("getState returns the saved destructive command guard setting", async () => { await provider.contextProxy.setValue("destructiveCommandGuardEnabled", true) @@ -3200,6 +3222,8 @@ describe("ClineProvider - Router Models", () => { expect(getModels).toHaveBeenCalledWith(expect.objectContaining({ provider: "opencode-go" })) // Kenari's /models endpoint is public, so it is fetched like the other no-auth routers. expect(getModels).toHaveBeenCalledWith(expect.objectContaining({ provider: "kenari" })) + // NanoGPT's detailed catalog is public and may be scoped by an optional key. + expect(getModels).toHaveBeenCalledWith({ provider: "nanogpt", apiKey: undefined }) // Verify response was sent expect(mockPostMessage).toHaveBeenCalledWith({ @@ -3218,6 +3242,7 @@ describe("ClineProvider - Router Models", () => { moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + nanogpt: mockModels, "kimi-code": {}, }, values: undefined, @@ -3252,6 +3277,7 @@ describe("ClineProvider - Router Models", () => { .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm fail .mockResolvedValueOnce(mockModels) // opencode-go (public endpoint) .mockResolvedValueOnce(mockModels) // kenari (public endpoint) + .mockResolvedValueOnce(mockModels) // nanogpt (public endpoint) await messageHandler({ type: "requestRouterModels" }) @@ -3272,6 +3298,7 @@ describe("ClineProvider - Router Models", () => { moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + nanogpt: mockModels, "kimi-code": {}, }, values: undefined, @@ -3372,6 +3399,7 @@ describe("ClineProvider - Router Models", () => { moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + nanogpt: mockModels, "kimi-code": {}, }, values: undefined, diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index a3b76aa8b2..bc92522790 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -485,6 +485,8 @@ describe("webviewMessageHandler - requestRouterModels", () => { expect(mockGetModels).toHaveBeenCalledWith(expect.objectContaining({ provider: "opencode-go" })) // Kenari's /models endpoint is public, so it is fetched like the other no-auth routers. expect(mockGetModels).toHaveBeenCalledWith(expect.objectContaining({ provider: "kenari" })) + // NanoGPT's detailed catalog is public and may optionally be scoped by a key. + expect(mockGetModels).toHaveBeenCalledWith({ provider: "nanogpt", apiKey: undefined }) // Verify response was sent expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ @@ -503,6 +505,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + nanogpt: mockModels, "kimi-code": {}, }, values: undefined, @@ -606,6 +609,53 @@ describe("webviewMessageHandler - requestRouterModels", () => { }) }) + it("fetches NanoGPT publicly without an API key", async () => { + mockClineProvider.getState = vi.fn().mockResolvedValue({ apiConfiguration: {} }) + mockGetModels.mockResolvedValue({ + "openai/gpt-5.6-sol": { maxTokens: 128000, contextWindow: 1050000, supportsPromptCache: false }, + }) + + await webviewMessageHandler(mockClineProvider, { + type: "requestRouterModels", + values: { provider: "nanogpt" }, + }) + + expect(mockGetModels).toHaveBeenCalledWith({ provider: "nanogpt", apiKey: undefined }) + expect(mockFlushModels).not.toHaveBeenCalled() + }) + + it("prefers an unsaved NanoGPT key and refreshes the matching key-scoped cache", async () => { + mockClineProvider.getState = vi.fn().mockResolvedValue({ + apiConfiguration: { nanoGptApiKey: "saved-key" }, + }) + mockGetModels.mockResolvedValue({ + "openai/gpt-5.6-sol": { maxTokens: 128000, contextWindow: 1050000, supportsPromptCache: false }, + }) + + await webviewMessageHandler(mockClineProvider, { + type: "requestRouterModels", + values: { provider: "nanogpt", nanoGptApiKey: "unsaved-key" }, + }) + + expect(mockFlushModels).toHaveBeenCalledWith({ provider: "nanogpt", apiKey: "unsaved-key" }, true) + expect(mockGetModels).toHaveBeenCalledWith({ provider: "nanogpt", apiKey: "unsaved-key" }) + }) + + it("uses the saved NanoGPT key for manual refresh", async () => { + mockClineProvider.getState = vi.fn().mockResolvedValue({ + apiConfiguration: { nanoGptApiKey: "saved-key" }, + }) + mockGetModels.mockResolvedValue({}) + + await webviewMessageHandler(mockClineProvider, { + type: "requestRouterModels", + values: { provider: "nanogpt", refresh: true }, + }) + + expect(mockFlushModels).toHaveBeenCalledWith({ provider: "nanogpt", apiKey: "saved-key" }, true) + expect(mockGetModels).toHaveBeenCalledWith({ provider: "nanogpt", apiKey: "saved-key" }) + }) + it("handles LiteLLM models with values from message when config is missing", async () => { mockClineProvider.getState = vi.fn().mockResolvedValue({ apiConfiguration: { @@ -691,6 +741,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + nanogpt: mockModels, "kimi-code": {}, }, values: undefined, @@ -716,6 +767,8 @@ describe("webviewMessageHandler - requestRouterModels", () => { .mockResolvedValueOnce(mockModels) // zoo-gateway .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm .mockResolvedValueOnce(mockModels) // opencode-go + .mockResolvedValueOnce(mockModels) // kenari + .mockResolvedValueOnce(mockModels) // nanogpt await webviewMessageHandler(mockClineProvider, { type: "requestRouterModels", @@ -753,6 +806,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + nanogpt: mockModels, "kimi-code": {}, }, values: undefined, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 5a28ce12d0..f0fc33501f 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1076,6 +1076,7 @@ export const webviewMessageHandler = async ( moonshot: {}, "opencode-go": {}, kenari: {}, + nanogpt: {}, "kimi-code": {}, } @@ -1219,6 +1220,19 @@ export const webviewMessageHandler = async ( options: { provider: "kenari", apiKey: kenariApiKey }, }) + // NanoGPT's detailed catalog is public, while an optional key can expose a + // different allowlist. Prefer an explicitly supplied unsaved key and use the + // same key-scoped options for refresh and retrieval. + const nanoGptApiKey = message?.values?.nanoGptApiKey ?? apiConfiguration.nanoGptApiKey + if (message?.values?.nanoGptApiKey !== undefined) { + await flushModels({ provider: "nanogpt", apiKey: nanoGptApiKey }, true) + } + + candidates.push({ + key: "nanogpt", + options: { provider: "nanogpt", apiKey: nanoGptApiKey }, + }) + if (!providerFilter || providerFilter === "kimi-code") { const { kimiCodeOAuthManager } = await import("../../integrations/kimi-code/oauth") const kimiCodeAuthMethod = diff --git a/src/shared/api.ts b/src/shared/api.ts index 056612f9f9..1787e15e88 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -189,6 +189,7 @@ const dynamicProviderExtras = { moonshot: {} as { apiKey?: string; baseUrl?: string }, "opencode-go": {} as { apiKey?: string }, kenari: {} as { apiKey?: string }, + nanogpt: {} as { apiKey?: string }, "kimi-code": {} as { apiKey?: string }, } as const satisfies Record diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 0cc61052db..782617640c 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -77,6 +77,7 @@ import { VercelAiGateway, OpenCodeGo, Kenari, + NanoGPT, ZooGateway, MiniMax, Mimo, @@ -677,6 +678,17 @@ const ApiOptions = ({ /> )} + {selectedProvider === providerIdentifiers.nanogpt && ( + + )} + {selectedProvider === providerIdentifiers.zooGateway && ( { 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"), @@ -350,6 +351,7 @@ describe("ApiOptions interactions", () => { providerIdentifiers.vercelAiGateway, providerIdentifiers.opencodeGo, providerIdentifiers.kenari, + providerIdentifiers.nanogpt, providerIdentifiers.zooGateway, providerIdentifiers.fireworks, providerIdentifiers.friendli, diff --git a/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx b/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx index 86e7273d45..c625650845 100644 --- a/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx @@ -420,6 +420,7 @@ describe("ApiOptions", () => { const optionTexts = Array.from(options).map((opt) => opt.textContent) expect(optionTexts).toContain("OpenAI") expect(optionTexts).toContain("Anthropic") + expect(optionTexts).toContain("NanoGPT") // Note: The mock doesn't implement search functionality, so we're just verifying // that the select element is rendered with the expected options diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx index 20eb7543eb..11b7de066d 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx @@ -603,4 +603,110 @@ describe("SettingsView - Unsaved Changes Detection", () => { }), ) }) + + it("buffers and saves the complete NanoGPT provider configuration from cached state", async () => { + const liveApiConfiguration = { + apiProvider: "nanogpt" as const, + nanoGptApiKey: "original-key", + nanoGptModelId: "openai/original", + nanoGptRoutingPreference: "auto" as const, + } + ;(useExtensionState as ReturnType).mockReturnValue({ + ...defaultExtensionState, + apiConfiguration: liveApiConfiguration, + }) + vi.mocked(ApiOptions).mockImplementation(({ apiConfiguration, setApiConfigurationField }) => ( +
+ setApiConfigurationField("nanoGptApiKey", event.target.value)} + /> + setApiConfigurationField("nanoGptModelId", event.target.value)} + /> + +
+ )) + + renderWithExtensionState(, { queryClient }) + + expect(await screen.findByTestId("cached-nanogpt-key")).toHaveValue("original-key") + expect(screen.getByTestId("cached-nanogpt-model")).toHaveValue("openai/original") + expect(screen.getByTestId("cached-nanogpt-routing")).toHaveValue("auto") + + fireEvent.change(screen.getByTestId("cached-nanogpt-key"), { target: { value: "unsaved-key" } }) + fireEvent.change(screen.getByTestId("cached-nanogpt-model"), { target: { value: "openai/next" } }) + fireEvent.change(screen.getByTestId("cached-nanogpt-routing"), { target: { value: "tools" } }) + + expect(liveApiConfiguration).toEqual({ + apiProvider: "nanogpt", + nanoGptApiKey: "original-key", + nanoGptModelId: "openai/original", + nanoGptRoutingPreference: "auto", + }) + expect(postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "upsertApiConfiguration" })) + + fireEvent.click(screen.getByTestId("save-button")) + + expect(postMessage).toHaveBeenCalledWith({ + type: "upsertApiConfiguration", + text: "default", + apiConfiguration: { + apiProvider: "nanogpt", + nanoGptApiKey: "unsaved-key", + nanoGptModelId: "openai/next", + nanoGptRoutingPreference: "tools", + }, + }) + }) + + it("discards NanoGPT cached edits and restores the extension values", async () => { + const onDone = vi.fn() + ;(useExtensionState as ReturnType).mockReturnValue({ + ...defaultExtensionState, + apiConfiguration: { + apiProvider: "nanogpt", + nanoGptApiKey: "saved-key", + nanoGptModelId: "openai/saved", + nanoGptRoutingPreference: "cheap", + }, + }) + vi.mocked(ApiOptions).mockImplementation(({ apiConfiguration, setApiConfigurationField }) => ( + setApiConfigurationField("nanoGptApiKey", event.target.value)} + /> + )) + + renderWithExtensionState(, { queryClient }) + fireEvent.change(await screen.findByTestId("cached-nanogpt-key"), { target: { value: "discard-me" } }) + fireEvent.click(screen.getByText("settings:common.done")) + fireEvent.click(await screen.findByText("settings:unsavedChangesDialog.discardButton")) + + await waitFor(() => expect(screen.getByTestId("cached-nanogpt-key")).toHaveValue("saved-key")) + expect(onDone).toHaveBeenCalledOnce() + expect(postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "upsertApiConfiguration" })) + }) }) diff --git a/webview-ui/src/components/settings/constants.ts b/webview-ui/src/components/settings/constants.ts index 7e5e10db6b..8c51aca2fe 100644 --- a/webview-ui/src/components/settings/constants.ts +++ b/webview-ui/src/components/settings/constants.ts @@ -70,6 +70,7 @@ export const PROVIDERS: Array<{ value: string; label: string; proxy: boolean }> { value: providerIdentifiers.vercelAiGateway, label: "Vercel AI Gateway", proxy: false }, { value: providerIdentifiers.opencodeGo, label: "Opencode Go", proxy: false }, { value: providerIdentifiers.kenari, label: "Kenari", proxy: false }, + { value: providerIdentifiers.nanogpt, label: "NanoGPT", proxy: false }, { value: providerIdentifiers.zooGateway, label: "Zoo Gateway", proxy: false }, { value: providerIdentifiers.minimax, label: "MiniMax", proxy: false }, { value: providerIdentifiers.mimo, label: "Xiaomi MiMo", proxy: false }, diff --git a/webview-ui/src/components/settings/providers/NanoGPT.tsx b/webview-ui/src/components/settings/providers/NanoGPT.tsx new file mode 100644 index 0000000000..cc7b7a431f --- /dev/null +++ b/webview-ui/src/components/settings/providers/NanoGPT.tsx @@ -0,0 +1,141 @@ +import { useCallback, useEffect } from "react" +import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" + +import { + type NanoGptRoutingPreference, + type OrganizationAllowList, + type ProviderSettings, + type RouterModels, + nanoGptDefaultModelId, + nanoGptDefaultRoutingPreference, + nanoGptRoutingPreferences, +} from "@roo-code/types" + +import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@src/components/ui" +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { vscode } from "@src/utils/vscode" + +import { ModelPicker } from "../ModelPicker" +import { inputEventTransform } from "../transforms" + +type NanoGPTProps = { + apiConfiguration: ProviderSettings + setApiConfigurationField: (field: K, value: ProviderSettings[K]) => void + routerModels?: RouterModels + organizationAllowList: OrganizationAllowList + modelValidationError?: string + simplifySettings?: boolean +} + +const routingOptionKeys: Record = { + auto: "automatic", + fast: "fastest", + cheap: "cheapest", + latency: "lowestLatency", + throughput: "highestThroughput", + tools: "toolCapable", + caching: "cacheCapable", +} + +export const NanoGPT = ({ + apiConfiguration, + setApiConfigurationField, + routerModels, + organizationAllowList, + modelValidationError, + simplifySettings, +}: NanoGPTProps) => { + const { t } = useAppTranslation() + const routingPreference = apiConfiguration.nanoGptRoutingPreference ?? nanoGptDefaultRoutingPreference + + const handleInputChange = useCallback( + ( + field: K, + transform: (event: E) => ProviderSettings[K] = inputEventTransform, + ) => + (event: E | Event) => { + setApiConfigurationField(field, transform(event as E)) + }, + [setApiConfigurationField], + ) + + useEffect(() => { + vscode.postMessage({ + type: "requestRouterModels", + values: { + provider: "nanogpt", + nanoGptApiKey: apiConfiguration.nanoGptApiKey, + }, + }) + }, [apiConfiguration.nanoGptApiKey]) + + return ( + <> + + + +
+ {t("settings:providers.apiKeyStorageNotice")} +
+ {!apiConfiguration.nanoGptApiKey && ( + + {t("settings:providers.nanoGpt.getApiKey")} + + )} + + + +
+ + +
+ {t("settings:providers.nanoGpt.automaticExplanation")} +
+
+ + {routingPreference !== nanoGptDefaultRoutingPreference && ( +
+

{t("settings:providers.nanoGpt.billingWarning")}

+ + {t("settings:providers.nanoGpt.routingDocs")} + +
+ )} + + ) +} diff --git a/webview-ui/src/components/settings/providers/__tests__/NanoGPT.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/NanoGPT.spec.tsx new file mode 100644 index 0000000000..d2bf7b3c04 --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/NanoGPT.spec.tsx @@ -0,0 +1,181 @@ +import { fireEvent, render, screen } from "@testing-library/react" + +import { + type OrganizationAllowList, + type ProviderSettings, + type RouterModels, + nanoGptDefaultModelId, + nanoGptRoutingPreferences, +} from "@roo-code/types" + +import { NanoGPT } from "../NanoGPT" + +const { postMessageMock } = vi.hoisted(() => ({ postMessageMock: vi.fn() })) + +vi.mock("@src/utils/vscode", () => ({ vscode: { postMessage: postMessageMock } })) + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ t: (key: string) => key }), +})) + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeTextField: ({ + children, + value, + onInput, + type, + }: React.ComponentProps<"input"> & { children: React.ReactNode }) => ( +
+ {children} + +
+ ), + VSCodeLink: ({ children, href }: React.ComponentProps<"a">) => {children}, +})) + +vi.mock("@src/components/common/VSCodeButtonLink", () => ({ + VSCodeButtonLink: ({ children, href }: React.ComponentProps<"a">) => ( + + {children} + + ), +})) + +vi.mock("@src/components/ui", () => ({ + Select: ({ + children, + value, + onValueChange, + }: { + children: React.ReactNode + value: string + onValueChange: (value: string) => void + }) => ( + + ), + SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}, + SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => ( + + ), + SelectTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + SelectValue: () => null, +})) + +vi.mock("../../ModelPicker", () => ({ + ModelPicker: ({ + defaultModelId, + models, + modelIdKey, + serviceName, + }: { + defaultModelId: string + models: object + modelIdKey: string + serviceName: string + }) => ( +
+ ), +})) + +describe("NanoGPT", () => { + const organizationAllowList: OrganizationAllowList = { allowAll: true, providers: {} } + const setApiConfigurationField = vi.fn() + const routerModels: RouterModels = { + openrouter: {}, + "vercel-ai-gateway": {}, + "zoo-gateway": {}, + litellm: {}, + requesty: {}, + unbound: {}, + poe: {}, + deepseek: {}, + moonshot: {}, + "opencode-go": {}, + kenari: {}, + nanogpt: { "openai/test": { contextWindow: 1, maxTokens: 1, supportsPromptCache: false } }, + "kimi-code": {}, + ollama: {}, + lmstudio: {}, + } + + const renderComponent = (apiConfiguration: ProviderSettings = {}) => + render( + , + ) + + beforeEach(() => vi.clearAllMocks()) + + it("renders the secret key input, CTA, dynamic model picker, routing copy, and links", () => { + renderComponent({ nanoGptRoutingPreference: "fast" }) + + expect(screen.getByTestId("nanogpt-api-key")).toHaveAttribute("type", "password") + expect(screen.getByText("settings:providers.nanoGpt.apiKey")).toBeInTheDocument() + expect(screen.getByTestId("nanogpt-get-key")).toHaveAttribute("href", "https://nano-gpt.com/api") + expect(screen.getByText("settings:providers.nanoGpt.getApiKey")).toBeInTheDocument() + expect(screen.getByTestId("model-picker")).toHaveAttribute("data-default-model-id", nanoGptDefaultModelId) + expect(screen.getByTestId("model-picker")).toHaveAttribute("data-model-id-key", "nanoGptModelId") + expect(screen.getByTestId("model-picker")).toHaveAttribute("data-model-count", "1") + expect(screen.getByText("settings:providers.nanoGpt.automaticExplanation")).toBeInTheDocument() + expect(screen.getByText("settings:providers.nanoGpt.billingWarning")).toBeInTheDocument() + expect(screen.getByText("settings:providers.nanoGpt.routingDocs")).toHaveAttribute( + "href", + "https://docs.nano-gpt.com/api-reference/miscellaneous/provider-selection", + ) + }) + + it("defaults routing to automatic and renders every routing option", () => { + renderComponent() + + expect(screen.getByTestId("routing-select")).toHaveValue("auto") + expect(screen.getAllByRole("option").map((option) => option.getAttribute("value"))).toEqual([ + ...nanoGptRoutingPreferences, + ]) + expect(screen.queryByText("settings:providers.nanoGpt.billingWarning")).not.toBeInTheDocument() + expect(screen.queryByText("settings:providers.nanoGpt.routingDocs")).not.toBeInTheDocument() + }) + + it("updates the cached key and routing preference with exact values", () => { + renderComponent({ nanoGptApiKey: "", nanoGptRoutingPreference: "fast" }) + + fireEvent.input(screen.getByTestId("nanogpt-api-key"), { target: { value: "new-secret" } }) + fireEvent.change(screen.getByTestId("routing-select"), { target: { value: "tools" } }) + + expect(setApiConfigurationField).toHaveBeenCalledWith("nanoGptApiKey", "new-secret") + expect(setApiConfigurationField).toHaveBeenCalledWith("nanoGptRoutingPreference", "tools") + }) + + it("refreshes models with the unsaved cached key whenever it changes", () => { + const { rerender } = renderComponent({ nanoGptApiKey: "first-key" }) + expect(postMessageMock).toHaveBeenLastCalledWith({ + type: "requestRouterModels", + values: { provider: "nanogpt", nanoGptApiKey: "first-key" }, + }) + + rerender( + , + ) + + expect(postMessageMock).toHaveBeenLastCalledWith({ + type: "requestRouterModels", + values: { provider: "nanogpt", nanoGptApiKey: "unsaved-key" }, + }) + }) +}) diff --git a/webview-ui/src/components/settings/providers/index.ts b/webview-ui/src/components/settings/providers/index.ts index 8d725efed2..12ea4cd786 100644 --- a/webview-ui/src/components/settings/providers/index.ts +++ b/webview-ui/src/components/settings/providers/index.ts @@ -26,6 +26,7 @@ export { Friendli } from "./Friendli" export { VercelAiGateway } from "./VercelAiGateway" export { OpenCodeGo } from "./OpenCodeGo" export { Kenari } from "./Kenari" +export { NanoGPT } from "./NanoGPT" export { ZooGateway } from "./ZooGateway" export { MiniMax } from "./MiniMax" export { Mimo } from "./Mimo" 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 153f30b0e2..e3b5ba84d4 100644 --- a/webview-ui/src/components/settings/utils/__tests__/providerModelConfig.spec.ts +++ b/webview-ui/src/components/settings/utils/__tests__/providerModelConfig.spec.ts @@ -1,10 +1,16 @@ -import { anthropicDefaultModelId, mainlandZAiDefaultModelId, providerIdentifiers } from "@roo-code/types" +import { + anthropicDefaultModelId, + mainlandZAiDefaultModelId, + nanoGptDefaultModelId, + providerIdentifiers, +} from "@roo-code/types" import { PROVIDER_SERVICE_CONFIG, PROVIDER_DEFAULT_MODEL_IDS, getProviderServiceConfig, getProviderModelConfig, + getProviderDocsSlug, getDefaultModelIdForProvider, getStaticModelsForProvider, isStaticModelProvider, @@ -159,6 +165,19 @@ describe("providerModelConfig", () => { const config = getProviderModelConfig(providerIdentifiers.anthropic) expect(config).toEqual({ field: "apiModelId", default: anthropicDefaultModelId }) }) + + it("returns NanoGPT's dynamic model field and fallback", () => { + expect(getProviderModelConfig(providerIdentifiers.nanogpt)).toEqual({ + field: "nanoGptModelId", + default: nanoGptDefaultModelId, + }) + }) + }) + + describe("getProviderDocsSlug", () => { + it("uses NanoGPT's provider identifier as its external documentation slug", () => { + expect(getProviderDocsSlug(providerIdentifiers.nanogpt)).toBe("nanogpt") + }) }) describe("getStaticModelsForProvider", () => { diff --git a/webview-ui/src/components/settings/utils/providerModelConfig.ts b/webview-ui/src/components/settings/utils/providerModelConfig.ts index eccbf7ba1d..a5a459e444 100644 --- a/webview-ui/src/components/settings/utils/providerModelConfig.ts +++ b/webview-ui/src/components/settings/utils/providerModelConfig.ts @@ -29,6 +29,7 @@ import { vercelAiGatewayDefaultModelId, opencodeGoDefaultModelId, kenariDefaultModelId, + nanoGptDefaultModelId, zooGatewayDefaultModelId, } from "@roo-code/types" @@ -143,6 +144,7 @@ const PROVIDER_MODEL_CONFIG: Partial> }, [providerIdentifiers.opencodeGo]: { field: "opencodeGoModelId", default: opencodeGoDefaultModelId }, [providerIdentifiers.kenari]: { field: "kenariModelId", default: kenariDefaultModelId }, + [providerIdentifiers.nanogpt]: { field: "nanoGptModelId", default: nanoGptDefaultModelId }, [providerIdentifiers.zooGateway]: { field: "zooGatewayModelId", default: zooGatewayDefaultModelId }, [providerIdentifiers.openai]: { field: "openAiModelId" }, [providerIdentifiers.ollama]: { field: "ollamaModelId" }, 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 2571614085..09bee757c4 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -15,6 +15,8 @@ import { litellmDefaultModelInfo, kenariDefaultModelId, kenariDefaultModelInfo, + nanoGptDefaultModelId, + nanoGptDefaultModelInfo, openAiModelInfoSaneDefaults, minimaxDefaultModelId, minimaxModels, @@ -1035,6 +1037,48 @@ describe("useSelectedModel", () => { }) }) + describe("nanogpt provider", () => { + it("uses dynamic metadata for the configured NanoGPT model", () => { + const dynamicInfo: ModelInfo = { + maxTokens: 16_384, + contextWindow: 256_000, + supportsPromptCache: false, + description: "Dynamic NanoGPT model", + } + mockUseRouterModels.mockReturnValue(createRouterModelsResult({ nanogpt: { "openai/custom": dynamicInfo } })) + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + + const { result } = renderHook( + () => + useSelectedModel({ + apiProvider: providerIdentifiers.nanogpt, + nanoGptModelId: "openai/custom", + }), + { wrapper: createWrapper() }, + ) + + expect(result.current.id).toBe("openai/custom") + expect(result.current.info).toEqual(dynamicInfo) + }) + + it("uses NanoGPT's shared fallback when its dynamic catalog is empty", () => { + mockUseRouterModels.mockReturnValue(createRouterModelsResult({ nanogpt: {} })) + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + + const { result } = renderHook( + () => + useSelectedModel({ + apiProvider: providerIdentifiers.nanogpt, + nanoGptModelId: "missing-model", + }), + { wrapper: createWrapper() }, + ) + + expect(result.current.id).toBe(nanoGptDefaultModelId) + expect(result.current.info).toEqual(nanoGptDefaultModelInfo) + }) + }) + describe("openai provider", () => { beforeEach(() => { mockUseRouterModels.mockReturnValue({ diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 6ed20ef47d..36b5554377 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -31,6 +31,7 @@ import { lMStudioDefaultModelInfo, opencodeGoDefaultModelInfo, kenariDefaultModelInfo, + nanoGptDefaultModelInfo, BEDROCK_1M_CONTEXT_MODEL_IDS, VERTEX_1M_CONTEXT_MODEL_IDS, isDynamicProvider, @@ -433,6 +434,15 @@ function getSelectedModel({ const info = routerModels[providerIdentifiers.kenari]?.[id] ?? kenariDefaultModelInfo return { id, info } } + case providerIdentifiers.nanogpt: { + const id = getValidatedModelId( + apiConfiguration.nanoGptModelId, + routerModels[providerIdentifiers.nanogpt], + defaultModelId, + ) + const info = routerModels[providerIdentifiers.nanogpt]?.[id] ?? nanoGptDefaultModelInfo + return { id, info } + } case providerIdentifiers.zooGateway: { const id = getValidatedModelId( apiConfiguration.zooGatewayModelId, diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 73827b1ec1..fa5cc11d65 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -388,6 +388,25 @@ "getOpencodeGoApiKey": "Obtenir clau API de Opencode Go", "kenariApiKey": "Clau API de Kenari", "getKenariApiKey": "Obtenir clau API de Kenari", + "nanoGpt": { + "provider": "NanoGPT", + "apiKey": "Clau API de NanoGPT", + "getApiKey": "Obtenir clau API de NanoGPT", + "model": "Model", + "routingPreference": "Preferència d'encaminament", + "routingOptions": { + "automatic": "Automàtica", + "fastest": "Més ràpida", + "cheapest": "Més econòmica", + "lowestLatency": "Latència més baixa", + "highestThroughput": "Rendiment més alt", + "toolCapable": "Compatible amb eines", + "cacheCapable": "Compatible amb memòria cau" + }, + "automaticExplanation": "L'opció Automàtica no aplica cap sufix d'encaminament.", + "billingWarning": "Les preferències d'encaminament avançades poden utilitzar encaminament de pagament per ús i poden quedar fora de la cobertura de la subscripció, segons les regles de facturació actuals de NanoGPT.", + "routingDocs": "Consulta els detalls d'encaminament i facturació de NanoGPT" + }, "apiKeyStorageNotice": "Les claus API s'emmagatzemen de forma segura a l'Emmagatzematge Secret de VSCode", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 8078037525..2cb83f7893 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -388,6 +388,25 @@ "getOpencodeGoApiKey": "Opencode Go API-Schlüssel erhalten", "kenariApiKey": "Kenari API-Schlüssel", "getKenariApiKey": "Kenari API-Schlüssel erhalten", + "nanoGpt": { + "provider": "NanoGPT", + "apiKey": "NanoGPT API-Schlüssel", + "getApiKey": "NanoGPT API-Schlüssel erhalten", + "model": "Modell", + "routingPreference": "Routing-Präferenz", + "routingOptions": { + "automatic": "Automatisch", + "fastest": "Am schnellsten", + "cheapest": "Am günstigsten", + "lowestLatency": "Niedrigste Latenz", + "highestThroughput": "Höchster Durchsatz", + "toolCapable": "Tool-fähig", + "cacheCapable": "Cache-fähig" + }, + "automaticExplanation": "Automatisch wendet kein Routing-Suffix an.", + "billingWarning": "Erweiterte Routing-Präferenzen können Pay-as-you-go-Routing verwenden und je nach den aktuellen Abrechnungsregeln von NanoGPT die Abdeckung deines Abos umgehen.", + "routingDocs": "Routing- und Abrechnungsdetails von NanoGPT anzeigen" + }, "apiKeyStorageNotice": "API-Schlüssel werden sicher im VSCode Secret Storage gespeichert", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 14a7476a75..a5967792a1 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -468,6 +468,25 @@ "getOpencodeGoApiKey": "Get Opencode Go API Key", "kenariApiKey": "Kenari API Key", "getKenariApiKey": "Get Kenari API Key", + "nanoGpt": { + "provider": "NanoGPT", + "apiKey": "NanoGPT API Key", + "getApiKey": "Get NanoGPT API Key", + "model": "Model", + "routingPreference": "Routing Preference", + "routingOptions": { + "automatic": "Automatic", + "fastest": "Fastest", + "cheapest": "Cheapest", + "lowestLatency": "Lowest latency", + "highestThroughput": "Highest throughput", + "toolCapable": "Tool-capable", + "cacheCapable": "Cache-capable" + }, + "automaticExplanation": "Automatic applies no routing suffix.", + "billingWarning": "Advanced routing preferences may use pay-as-you-go routing and may bypass subscription coverage, depending on NanoGPT's current billing rules.", + "routingDocs": "View NanoGPT routing and billing details" + }, "apiKeyStorageNotice": "API keys are stored securely in VSCode's Secret Storage", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index e629e43b50..305a8dd5d7 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -388,6 +388,25 @@ "getOpencodeGoApiKey": "Obtener clave API de Opencode Go", "kenariApiKey": "Clave API de Kenari", "getKenariApiKey": "Obtener clave API de Kenari", + "nanoGpt": { + "provider": "NanoGPT", + "apiKey": "Clave API de NanoGPT", + "getApiKey": "Obtener clave API de NanoGPT", + "model": "Modelo", + "routingPreference": "Preferencia de enrutamiento", + "routingOptions": { + "automatic": "Automática", + "fastest": "Más rápida", + "cheapest": "Más barata", + "lowestLatency": "Menor latencia", + "highestThroughput": "Mayor rendimiento", + "toolCapable": "Compatible con herramientas", + "cacheCapable": "Compatible con caché" + }, + "automaticExplanation": "Automática no aplica ningún sufijo de enrutamiento.", + "billingWarning": "Las preferencias de enrutamiento avanzadas pueden usar enrutamiento de pago por uso y pueden omitir la cobertura de la suscripción, según las reglas de facturación actuales de NanoGPT.", + "routingDocs": "Ver detalles de enrutamiento y facturación de NanoGPT" + }, "apiKeyStorageNotice": "Las claves API se almacenan de forma segura en el Almacenamiento Secreto de VSCode", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 6048e2274c..d5728833dd 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -388,6 +388,25 @@ "getOpencodeGoApiKey": "Obtenir la clé API Opencode Go", "kenariApiKey": "Clé API Kenari", "getKenariApiKey": "Obtenir la clé API Kenari", + "nanoGpt": { + "provider": "NanoGPT", + "apiKey": "Clé API NanoGPT", + "getApiKey": "Obtenir une clé API NanoGPT", + "model": "Modèle", + "routingPreference": "Préférence de routage", + "routingOptions": { + "automatic": "Automatique", + "fastest": "Le plus rapide", + "cheapest": "Le moins cher", + "lowestLatency": "Latence la plus faible", + "highestThroughput": "Débit le plus élevé", + "toolCapable": "Compatible avec les outils", + "cacheCapable": "Compatible avec le cache" + }, + "automaticExplanation": "Automatique n'applique aucun suffixe de routage.", + "billingWarning": "Les préférences de routage avancées peuvent utiliser une facturation à l'usage et peuvent contourner la couverture de l'abonnement, selon les règles de facturation actuelles de NanoGPT.", + "routingDocs": "Voir les détails de routage et de facturation de NanoGPT" + }, "apiKeyStorageNotice": "Les clés API sont stockées en toute sécurité dans le stockage sécurisé de VSCode", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 28d0b8699b..3fce97a378 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -388,6 +388,25 @@ "getOpencodeGoApiKey": "Opencode Go API कुंजी प्राप्त करें", "kenariApiKey": "Kenari API कुंजी", "getKenariApiKey": "Kenari API कुंजी प्राप्त करें", + "nanoGpt": { + "provider": "NanoGPT", + "apiKey": "NanoGPT API कुंजी", + "getApiKey": "NanoGPT API कुंजी प्राप्त करें", + "model": "मॉडल", + "routingPreference": "रूटिंग प्राथमिकता", + "routingOptions": { + "automatic": "स्वचालित", + "fastest": "सबसे तेज़", + "cheapest": "सबसे सस्ता", + "lowestLatency": "सबसे कम विलंबता", + "highestThroughput": "सबसे अधिक थ्रूपुट", + "toolCapable": "टूल-सक्षम", + "cacheCapable": "कैश-सक्षम" + }, + "automaticExplanation": "स्वचालित कोई रूटिंग प्रत्यय लागू नहीं करता।", + "billingWarning": "NanoGPT के मौजूदा बिलिंग नियमों के आधार पर उन्नत रूटिंग प्राथमिकताएँ भुगतान-प्रति-उपयोग रूटिंग का उपयोग कर सकती हैं और सदस्यता कवरेज को छोड़ सकती हैं।", + "routingDocs": "NanoGPT रूटिंग और बिलिंग विवरण देखें" + }, "apiKeyStorageNotice": "API कुंजियाँ VSCode के सुरक्षित स्टोरेज में सुरक्षित रूप से संग्रहीत हैं", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index bf049395c4..bcdd0ae76d 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -388,6 +388,25 @@ "getOpencodeGoApiKey": "Dapatkan Opencode Go API Key", "kenariApiKey": "Kenari API Key", "getKenariApiKey": "Dapatkan Kenari API Key", + "nanoGpt": { + "provider": "NanoGPT", + "apiKey": "NanoGPT API Key", + "getApiKey": "Dapatkan NanoGPT API Key", + "model": "Model", + "routingPreference": "Preferensi Routing", + "routingOptions": { + "automatic": "Otomatis", + "fastest": "Tercepat", + "cheapest": "Termurah", + "lowestLatency": "Latensi terendah", + "highestThroughput": "Throughput tertinggi", + "toolCapable": "Mendukung tool", + "cacheCapable": "Mendukung cache" + }, + "automaticExplanation": "Otomatis tidak menerapkan akhiran routing.", + "billingWarning": "Preferensi routing lanjutan dapat menggunakan routing bayar sesuai pemakaian dan dapat melewati cakupan langganan, tergantung aturan penagihan NanoGPT saat ini.", + "routingDocs": "Lihat detail routing dan penagihan NanoGPT" + }, "apiKeyStorageNotice": "API key disimpan dengan aman di Secret Storage VSCode", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 577a74a77a..b22fb4c652 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -388,6 +388,25 @@ "getOpencodeGoApiKey": "Ottieni chiave API Opencode Go", "kenariApiKey": "Chiave API Kenari", "getKenariApiKey": "Ottieni chiave API Kenari", + "nanoGpt": { + "provider": "NanoGPT", + "apiKey": "Chiave API NanoGPT", + "getApiKey": "Ottieni chiave API NanoGPT", + "model": "Modello", + "routingPreference": "Preferenza di routing", + "routingOptions": { + "automatic": "Automatica", + "fastest": "Più veloce", + "cheapest": "Più economica", + "lowestLatency": "Latenza più bassa", + "highestThroughput": "Throughput più alto", + "toolCapable": "Compatibile con gli strumenti", + "cacheCapable": "Compatibile con la cache" + }, + "automaticExplanation": "Automatica non applica alcun suffisso di routing.", + "billingWarning": "Le preferenze di routing avanzate possono usare il routing a consumo e possono escludere la copertura dell'abbonamento, in base alle regole di fatturazione attuali di NanoGPT.", + "routingDocs": "Visualizza i dettagli di routing e fatturazione di NanoGPT" + }, "apiKeyStorageNotice": "Le chiavi API sono memorizzate in modo sicuro nell'Archivio Segreto di VSCode", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 1113ac32a6..bbdc5c8e8a 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -388,6 +388,25 @@ "getOpencodeGoApiKey": "Opencode Go APIキーを取得", "kenariApiKey": "Kenari APIキー", "getKenariApiKey": "Kenari APIキーを取得", + "nanoGpt": { + "provider": "NanoGPT", + "apiKey": "NanoGPT APIキー", + "getApiKey": "NanoGPT APIキーを取得", + "model": "モデル", + "routingPreference": "ルーティング設定", + "routingOptions": { + "automatic": "自動", + "fastest": "最速", + "cheapest": "最安", + "lowestLatency": "最低レイテンシ", + "highestThroughput": "最高スループット", + "toolCapable": "ツール対応", + "cacheCapable": "キャッシュ対応" + }, + "automaticExplanation": "自動ではルーティングサフィックスを適用しません。", + "billingWarning": "高度なルーティング設定では従量課金ルーティングが使用され、NanoGPTの現在の請求ルールによってはサブスクリプションの対象外になる場合があります。", + "routingDocs": "NanoGPTのルーティングと請求の詳細を表示" + }, "apiKeyStorageNotice": "APIキーはVSCodeのシークレットストレージに安全に保存されます", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 27e8493bd4..c2062a5335 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -388,6 +388,25 @@ "getOpencodeGoApiKey": "Opencode Go API 키 받기", "kenariApiKey": "Kenari API 키", "getKenariApiKey": "Kenari API 키 받기", + "nanoGpt": { + "provider": "NanoGPT", + "apiKey": "NanoGPT API 키", + "getApiKey": "NanoGPT API 키 받기", + "model": "모델", + "routingPreference": "라우팅 기본 설정", + "routingOptions": { + "automatic": "자동", + "fastest": "가장 빠름", + "cheapest": "가장 저렴함", + "lowestLatency": "최저 지연 시간", + "highestThroughput": "최고 처리량", + "toolCapable": "도구 지원", + "cacheCapable": "캐시 지원" + }, + "automaticExplanation": "자동은 라우팅 접미사를 적용하지 않습니다.", + "billingWarning": "고급 라우팅 기본 설정은 사용량 기반 라우팅을 사용할 수 있으며 NanoGPT의 현재 결제 규칙에 따라 구독 범위를 벗어날 수 있습니다.", + "routingDocs": "NanoGPT 라우팅 및 결제 세부 정보 보기" + }, "apiKeyStorageNotice": "API 키는 VSCode의 보안 저장소에 안전하게 저장됩니다", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 394cdd48f2..cf148f5617 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -388,6 +388,25 @@ "getOpencodeGoApiKey": "Opencode Go API-sleutel ophalen", "kenariApiKey": "Kenari API-sleutel", "getKenariApiKey": "Kenari API-sleutel ophalen", + "nanoGpt": { + "provider": "NanoGPT", + "apiKey": "NanoGPT API-sleutel", + "getApiKey": "NanoGPT API-sleutel ophalen", + "model": "Model", + "routingPreference": "Routeringsvoorkeur", + "routingOptions": { + "automatic": "Automatisch", + "fastest": "Snelste", + "cheapest": "Goedkoopste", + "lowestLatency": "Laagste latentie", + "highestThroughput": "Hoogste doorvoer", + "toolCapable": "Geschikt voor tools", + "cacheCapable": "Geschikt voor cache" + }, + "automaticExplanation": "Automatisch past geen routeringsachtervoegsel toe.", + "billingWarning": "Geavanceerde routeringsvoorkeuren kunnen betalen-per-gebruik-routering gebruiken en kunnen, afhankelijk van de huidige factureringsregels van NanoGPT, buiten je abonnement vallen.", + "routingDocs": "Routerings- en factureringsdetails van NanoGPT bekijken" + }, "apiKeyStorageNotice": "API-sleutels worden veilig opgeslagen in de geheime opslag van VSCode", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 864e4ffde1..ace780f529 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -388,6 +388,25 @@ "getOpencodeGoApiKey": "Uzyskaj klucz API Opencode Go", "kenariApiKey": "Klucz API Kenari", "getKenariApiKey": "Uzyskaj klucz API Kenari", + "nanoGpt": { + "provider": "NanoGPT", + "apiKey": "Klucz API NanoGPT", + "getApiKey": "Uzyskaj klucz API NanoGPT", + "model": "Model", + "routingPreference": "Preferencja routingu", + "routingOptions": { + "automatic": "Automatyczny", + "fastest": "Najszybszy", + "cheapest": "Najtańszy", + "lowestLatency": "Najniższe opóźnienie", + "highestThroughput": "Najwyższa przepustowość", + "toolCapable": "Obsługa narzędzi", + "cacheCapable": "Obsługa pamięci podręcznej" + }, + "automaticExplanation": "Automatyczny nie dodaje sufiksu routingu.", + "billingWarning": "Zaawansowane preferencje routingu mogą używać rozliczeń za użycie i mogą omijać zakres subskrypcji, zależnie od aktualnych zasad rozliczeń NanoGPT.", + "routingDocs": "Zobacz szczegóły routingu i rozliczeń NanoGPT" + }, "apiKeyStorageNotice": "Klucze API są bezpiecznie przechowywane w Tajnym Magazynie VSCode", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index a1948a0218..446aa8ac02 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -388,6 +388,25 @@ "getOpencodeGoApiKey": "Obter chave API do Opencode Go", "kenariApiKey": "Chave API do Kenari", "getKenariApiKey": "Obter chave API do Kenari", + "nanoGpt": { + "provider": "NanoGPT", + "apiKey": "Chave API do NanoGPT", + "getApiKey": "Obter chave API do NanoGPT", + "model": "Modelo", + "routingPreference": "Preferência de roteamento", + "routingOptions": { + "automatic": "Automática", + "fastest": "Mais rápida", + "cheapest": "Mais barata", + "lowestLatency": "Menor latência", + "highestThroughput": "Maior throughput", + "toolCapable": "Compatível com ferramentas", + "cacheCapable": "Compatível com cache" + }, + "automaticExplanation": "Automática não aplica nenhum sufixo de roteamento.", + "billingWarning": "Preferências de roteamento avançadas podem usar cobrança por uso e podem ignorar a cobertura da assinatura, dependendo das regras de cobrança atuais do NanoGPT.", + "routingDocs": "Ver detalhes de roteamento e cobrança do NanoGPT" + }, "apiKeyStorageNotice": "As chaves de API são armazenadas com segurança no Armazenamento Secreto do VSCode", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index f36fe62539..f2719ad06e 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -388,6 +388,25 @@ "getOpencodeGoApiKey": "Получить ключ API Opencode Go", "kenariApiKey": "Ключ API Kenari", "getKenariApiKey": "Получить ключ API Kenari", + "nanoGpt": { + "provider": "NanoGPT", + "apiKey": "Ключ API NanoGPT", + "getApiKey": "Получить ключ API NanoGPT", + "model": "Модель", + "routingPreference": "Предпочтение маршрутизации", + "routingOptions": { + "automatic": "Автоматически", + "fastest": "Самая быстрая", + "cheapest": "Самая дешёвая", + "lowestLatency": "Минимальная задержка", + "highestThroughput": "Максимальная пропускная способность", + "toolCapable": "С поддержкой инструментов", + "cacheCapable": "С поддержкой кеша" + }, + "automaticExplanation": "Автоматический режим не применяет суффикс маршрутизации.", + "billingWarning": "Расширенные предпочтения маршрутизации могут использовать оплату по факту использования и могут обходить покрытие подписки в зависимости от текущих правил оплаты NanoGPT.", + "routingDocs": "Подробнее о маршрутизации и оплате NanoGPT" + }, "apiKeyStorageNotice": "API-ключи хранятся безопасно в Secret Storage VSCode", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 9099677679..08374b6d20 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -388,6 +388,25 @@ "getOpencodeGoApiKey": "Opencode Go API Anahtarı Al", "kenariApiKey": "Kenari API Anahtarı", "getKenariApiKey": "Kenari API Anahtarı Al", + "nanoGpt": { + "provider": "NanoGPT", + "apiKey": "NanoGPT API Anahtarı", + "getApiKey": "NanoGPT API Anahtarı Al", + "model": "Model", + "routingPreference": "Yönlendirme Tercihi", + "routingOptions": { + "automatic": "Otomatik", + "fastest": "En hızlı", + "cheapest": "En ucuz", + "lowestLatency": "En düşük gecikme", + "highestThroughput": "En yüksek verim", + "toolCapable": "Araç destekli", + "cacheCapable": "Önbellek destekli" + }, + "automaticExplanation": "Otomatik, yönlendirme son eki uygulamaz.", + "billingWarning": "Gelişmiş yönlendirme tercihleri kullanıma göre ödeme yönlendirmesini kullanabilir ve NanoGPT'nin güncel faturalandırma kurallarına bağlı olarak abonelik kapsamını atlayabilir.", + "routingDocs": "NanoGPT yönlendirme ve faturalandırma ayrıntılarını görüntüle" + }, "apiKeyStorageNotice": "API anahtarları VSCode'un Gizli Depolamasında güvenli bir şekilde saklanır", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index c66b236165..a8611ca687 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -388,6 +388,25 @@ "getOpencodeGoApiKey": "Lấy khóa API Opencode Go", "kenariApiKey": "Khóa API Kenari", "getKenariApiKey": "Lấy khóa API Kenari", + "nanoGpt": { + "provider": "NanoGPT", + "apiKey": "Khóa API NanoGPT", + "getApiKey": "Lấy khóa API NanoGPT", + "model": "Mô hình", + "routingPreference": "Tùy chọn định tuyến", + "routingOptions": { + "automatic": "Tự động", + "fastest": "Nhanh nhất", + "cheapest": "Rẻ nhất", + "lowestLatency": "Độ trễ thấp nhất", + "highestThroughput": "Thông lượng cao nhất", + "toolCapable": "Hỗ trợ công cụ", + "cacheCapable": "Hỗ trợ bộ nhớ đệm" + }, + "automaticExplanation": "Tự động không áp dụng hậu tố định tuyến.", + "billingWarning": "Tùy chọn định tuyến nâng cao có thể sử dụng định tuyến trả theo mức dùng và có thể không thuộc phạm vi gói đăng ký, tùy theo quy tắc thanh toán hiện tại của NanoGPT.", + "routingDocs": "Xem chi tiết định tuyến và thanh toán NanoGPT" + }, "apiKeyStorageNotice": "Khóa API được lưu trữ an toàn trong Bộ lưu trữ bí mật của VSCode", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 22742e0e0e..9f3913e872 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -388,6 +388,25 @@ "getOpencodeGoApiKey": "获取 Opencode Go API 密钥", "kenariApiKey": "Kenari API 密钥", "getKenariApiKey": "获取 Kenari API 密钥", + "nanoGpt": { + "provider": "NanoGPT", + "apiKey": "NanoGPT API 密钥", + "getApiKey": "获取 NanoGPT API 密钥", + "model": "模型", + "routingPreference": "路由偏好", + "routingOptions": { + "automatic": "自动", + "fastest": "最快", + "cheapest": "最便宜", + "lowestLatency": "最低延迟", + "highestThroughput": "最高吞吐量", + "toolCapable": "支持工具", + "cacheCapable": "支持缓存" + }, + "automaticExplanation": "自动模式不应用路由后缀。", + "billingWarning": "高级路由偏好可能使用按用量付费路由,并且根据 NanoGPT 当前的计费规则,可能不在订阅范围内。", + "routingDocs": "查看 NanoGPT 路由和计费详情" + }, "apiKeyStorageNotice": "API 密钥安全存储在 VSCode 的密钥存储中", "openAiCodexRateLimits": { "title": "Usage Limits for Codex{{planLabel}}", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 4255a2e697..80d0d18735 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -415,6 +415,25 @@ "getOpencodeGoApiKey": "取得 Opencode Go API 金鑰", "kenariApiKey": "Kenari API 金鑰", "getKenariApiKey": "取得 Kenari API 金鑰", + "nanoGpt": { + "provider": "NanoGPT", + "apiKey": "NanoGPT API 金鑰", + "getApiKey": "取得 NanoGPT API 金鑰", + "model": "模型", + "routingPreference": "路由偏好", + "routingOptions": { + "automatic": "自動", + "fastest": "最快", + "cheapest": "最便宜", + "lowestLatency": "最低延遲", + "highestThroughput": "最高吞吐量", + "toolCapable": "支援工具", + "cacheCapable": "支援快取" + }, + "automaticExplanation": "自動模式不套用路由後綴。", + "billingWarning": "進階路由偏好可能使用隨用隨付路由,並且根據 NanoGPT 目前的計費規則,可能不在訂閱涵蓋範圍內。", + "routingDocs": "查看 NanoGPT 路由與計費詳細資訊" + }, "apiKeyStorageNotice": "API 金鑰會安全地儲存在 VS Code 的 Secret Storage 中", "openAiCodexRateLimits": { "title": "Codex 用量限制{{planLabel}}", diff --git a/webview-ui/src/utils/__tests__/validate.spec.ts b/webview-ui/src/utils/__tests__/validate.spec.ts index 8e2e223977..6535e3ba61 100644 --- a/webview-ui/src/utils/__tests__/validate.spec.ts +++ b/webview-ui/src/utils/__tests__/validate.spec.ts @@ -58,6 +58,7 @@ describe("Model Validation Functions", () => { deepseek: {}, "opencode-go": {}, kenari: {}, + nanogpt: {}, "zoo-gateway": {}, "kimi-code": {}, moonshot: {}, @@ -295,6 +296,36 @@ describe("Model Validation Functions", () => { }) }) + describe("NanoGPT validation", () => { + it("returns an apiKey error when the NanoGPT API key is missing", () => { + const config: ProviderSettings = { + apiProvider: providerIdentifiers.nanogpt, + nanoGptModelId: "openai/model", + } + + expect(validateApiConfiguration(config, mockRouterModels)).toBe("settings:validation.apiKey") + }) + + it("returns a modelId error when the NanoGPT model is missing", () => { + const config: ProviderSettings = { + apiProvider: providerIdentifiers.nanogpt, + nanoGptApiKey: "valid-key", + } + + expect(validateApiConfiguration(config, mockRouterModels)).toBe("settings:validation.modelId") + }) + + it("accepts a NanoGPT API key and selected model", () => { + const config: ProviderSettings = { + apiProvider: providerIdentifiers.nanogpt, + nanoGptApiKey: "valid-key", + nanoGptModelId: "openai/model", + } + + expect(validateApiConfiguration(config, mockRouterModels)).toBeUndefined() + }) + }) + describe("Friendli validation", () => { it("returns an apiKey error when the Friendli API key is missing", () => { const config: ProviderSettings = { diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index e946906e10..4174e8f821 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -143,6 +143,11 @@ function validateModelsAndKeysProvided( return i18next.t("settings:validation.apiKey") } break + case providerIdentifiers.nanogpt: + if (!apiConfiguration.nanoGptApiKey) { + return i18next.t("settings:validation.apiKey") + } + break case providerIdentifiers.zooGateway: if (!apiConfiguration.zooSessionToken && !zooCodeIsAuthenticated) { return i18next.t("settings:validation.zooGatewaySignIn")