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 0fd5f8c209..706ff87576 100644 --- a/apps/cli/src/lib/utils/__tests__/context-window.test.ts +++ b/apps/cli/src/lib/utils/__tests__/context-window.test.ts @@ -15,6 +15,7 @@ describe("getContextWindow", () => { [providerIdentifiers.opencodeGo, "opencodeGoModelId"], [providerIdentifiers.kenari, "kenariModelId"], [providerIdentifiers.ioIntelligence, "ioIntelligenceModelId"], + [providerIdentifiers.standardCompute, "standardComputeModelId"], [providerIdentifiers.nanogpt, "nanoGptModelId"], [providerIdentifiers.zooGateway, "zooGatewayModelId"], ] as const)("uses the provider-specific model field for %s", (provider, modelField) => { diff --git a/packages/types/src/__tests__/provider-identifiers.test.ts b/packages/types/src/__tests__/provider-identifiers.test.ts index af4de232f9..a442a0a707 100644 --- a/packages/types/src/__tests__/provider-identifiers.test.ts +++ b/packages/types/src/__tests__/provider-identifiers.test.ts @@ -36,6 +36,7 @@ const expectedProviderIdentifiers = [ "kenari", "nanogpt", "io-intelligence", + "standard-compute", "ollama", "lmstudio", "vscode-lm", @@ -113,6 +114,7 @@ describe("provider identifiers", () => { providerIdentifiers.kenari, providerIdentifiers.nanogpt, providerIdentifiers.ioIntelligence, + providerIdentifiers.standardCompute, 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 69d77122aa..58d649bed6 100644 --- a/packages/types/src/__tests__/provider-model-id.test.ts +++ b/packages/types/src/__tests__/provider-model-id.test.ts @@ -22,6 +22,7 @@ const expectedModelIdKeys = [ "kenariModelId", "nanoGptModelId", "ioIntelligenceModelId", + "standardComputeModelId", "zooGatewayModelId", ] as const diff --git a/packages/types/src/__tests__/standard-compute.test.ts b/packages/types/src/__tests__/standard-compute.test.ts new file mode 100644 index 0000000000..d886f2ad9f --- /dev/null +++ b/packages/types/src/__tests__/standard-compute.test.ts @@ -0,0 +1,26 @@ +import { + dynamicProviders, + getModelId, + getProviderDefaultModelId, + standardComputeDefaultModelId, + isSecretStateKey, + providerIdentifiers, + providerSettingsSchema, +} from "../index.js" + +describe("Standard Compute shared contract", () => { + it("registers the stable dynamic-provider identity and default model", () => { + expect(providerIdentifiers.standardCompute).toBe("standard-compute") + expect(dynamicProviders).toContain(providerIdentifiers.standardCompute) + expect(getProviderDefaultModelId(providerIdentifiers.standardCompute, {})).toBe(standardComputeDefaultModelId) + }) + + it("classifies the API key as secret and resolves the configured model", () => { + expect(isSecretStateKey("standardComputeApiKey")).toBe(true) + const settings = providerSettingsSchema.parse({ + apiProvider: providerIdentifiers.standardCompute, + standardComputeModelId: "model", + }) + expect(getModelId(settings)).toBe("model") + }) +}) diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index f5757a51c7..256cccb478 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -381,6 +381,7 @@ export const SECRET_STATE_KEYS = [ "opencodeGoApiKey", "kenariApiKey", "ioIntelligenceApiKey", + "standardComputeApiKey", "nanoGptApiKey", "basetenApiKey", ] as const diff --git a/packages/types/src/provider-identifiers.ts b/packages/types/src/provider-identifiers.ts index 605e7d24d2..dbdd83f647 100644 --- a/packages/types/src/provider-identifiers.ts +++ b/packages/types/src/provider-identifiers.ts @@ -16,6 +16,7 @@ export const providerIdentifiers = { kenari: "kenari", nanogpt: "nanogpt", ioIntelligence: "io-intelligence", + standardCompute: "standard-compute", ollama: "ollama", lmstudio: "lmstudio", vscodeLm: "vscode-lm", diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index ebb48b041b..3bc87d8459 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -101,6 +101,7 @@ export const dynamicProviders = [ providerIdentifiers.kenari, providerIdentifiers.nanogpt, providerIdentifiers.ioIntelligence, + providerIdentifiers.standardCompute, providerIdentifiers.kimiCode, ] as const @@ -316,6 +317,7 @@ export const modelIdKeys = [ "kenariModelId", "nanoGptModelId", "ioIntelligenceModelId", + "standardComputeModelId", "zooGatewayModelId", ] as const satisfies readonly ModelIdKey[] @@ -562,6 +564,11 @@ export const MODELS_BY_PROVIDER: Record< label: "IO Intelligence", models: [], }, + [providerIdentifiers.standardCompute]: { + id: providerIdentifiers.standardCompute, + label: "Standard Compute", + models: [], + }, [providerIdentifiers.nanogpt]: { id: providerIdentifiers.nanogpt, label: "NanoGPT", models: [] }, [providerIdentifiers.zooGateway]: { id: providerIdentifiers.zooGateway, label: "Zoo Gateway", models: [] }, diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index 63c1585fdf..4974c0b8ca 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -31,6 +31,7 @@ export * from "./vercel-ai-gateway.js" export * from "./opencode-go.js" export * from "./kenari.js" export * from "./io-intelligence.js" +export * from "./standard-compute.js" export * from "./nanogpt.js" export * from "./kimi-code.js" export * from "./zai.js" @@ -69,6 +70,7 @@ import { poeProviderDefinition } from "./poe.js" import { qwenCodeProviderDefinition } from "./qwen-code.js" import { requestyProviderDefinition } from "./requesty.js" import { sambaNovaProviderDefinition } from "./sambanova.js" +import { standardComputeProviderDefinition } from "./standard-compute.js" import { unboundProviderDefinition } from "./unbound.js" import { vercelAiGatewayProviderDefinition } from "./vercel-ai-gateway.js" import { vertexProviderDefinition } from "./vertex.js" @@ -115,6 +117,7 @@ export const providerDefinitionList = [ kenariProviderDefinition, nanoGptProviderDefinition, ioIntelligenceProviderDefinition, + standardComputeProviderDefinition, zooGatewayProviderDefinition, basetenProviderDefinition, ] as const satisfies readonly ProviderDefinition[] diff --git a/packages/types/src/providers/standard-compute.ts b/packages/types/src/providers/standard-compute.ts new file mode 100644 index 0000000000..4958a9f4f7 --- /dev/null +++ b/packages/types/src/providers/standard-compute.ts @@ -0,0 +1,37 @@ +import { z } from "zod" + +import { type ModelInfo } from "../model.js" +import { providerIdentifiers } from "../provider-identifiers.js" +import { baseProviderSettingsShape, createModelIdAccessor, createProviderDefinition } from "./common.js" + +export const STANDARD_COMPUTE_BASE_URL = "https://api.stdcmpt.com/v1" + +/** + * The routed model. The router picks a model from the models that the user + * enabled, by the user's quality preference. It uses cheaper models for simple + * steps and sends image requests to a vision model. + */ +export const standardComputeDefaultModelId = "StandardCompute" + +export const standardComputeDefaultModelInfo: ModelInfo = { + maxTokens: 32_768, + contextWindow: 1_000_000, + supportsImages: true, + supportsPromptCache: false, + description: + "Standard Compute routed model. The router picks a model from your enabled models by your quality preference and uses cheaper models for simple steps. Image requests go to a vision model.", +} + +const STANDARD_COMPUTE_MODEL_ID_FIELD = "standardComputeModelId" + +export const standardComputeProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.standardCompute, + modelIdKey: STANDARD_COMPUTE_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(STANDARD_COMPUTE_MODEL_ID_FIELD), + getDefaultModelId: () => standardComputeDefaultModelId, + schema: { + ...baseProviderSettingsShape, + standardComputeApiKey: z.string().optional(), + [STANDARD_COMPUTE_MODEL_ID_FIELD]: z.string().optional(), + }, +}) diff --git a/src/api/__tests__/index.spec.ts b/src/api/__tests__/index.spec.ts index 7601641511..3ff5476830 100644 --- a/src/api/__tests__/index.spec.ts +++ b/src/api/__tests__/index.spec.ts @@ -75,6 +75,7 @@ import { QwenCodeHandler, RequestyHandler, SambaNovaHandler, + StandardComputeHandler, UnboundHandler, VercelAiGatewayHandler, VertexHandler, @@ -121,6 +122,7 @@ const expectedHandlers = { [providerIdentifiers.opencodeGo]: OpencodeGoHandler, [providerIdentifiers.kenari]: KenariHandler, [providerIdentifiers.ioIntelligence]: IOIntelligenceHandler, + [providerIdentifiers.standardCompute]: StandardComputeHandler, [providerIdentifiers.nanogpt]: NanoGptHandler, [providerIdentifiers.zooGateway]: ZooGatewayHandler, [providerIdentifiers.minimax]: MiniMaxHandler, diff --git a/src/api/index.ts b/src/api/index.ts index e737ebd4bd..a4d16e7343 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -46,6 +46,7 @@ import { OpencodeGoHandler, KenariHandler, IOIntelligenceHandler, + StandardComputeHandler, NanoGptHandler, ZooGatewayHandler, MiniMaxHandler, @@ -274,6 +275,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { return new KenariHandler(options) case providerIdentifiers.ioIntelligence: return new IOIntelligenceHandler(options) + case providerIdentifiers.standardCompute: + return new StandardComputeHandler(options) case providerIdentifiers.nanogpt: return new NanoGptHandler(options) case providerIdentifiers.zooGateway: diff --git a/src/api/providers/__tests__/model-catalog-adapters.spec.ts b/src/api/providers/__tests__/model-catalog-adapters.spec.ts index 58c4c2dcbf..cf28443a54 100644 --- a/src/api/providers/__tests__/model-catalog-adapters.spec.ts +++ b/src/api/providers/__tests__/model-catalog-adapters.spec.ts @@ -12,6 +12,7 @@ import type { ApiHandler, ModelCacheScope } from "../../index" const fetchers = vi.hoisted(() => ({ getDeepSeekModels: vi.fn(), getIOIntelligenceModels: vi.fn(), + getStandardComputeModels: vi.fn(), getKenariModels: vi.fn(), getKimiCodeModels: vi.fn(), getLiteLLMModels: vi.fn(), @@ -50,6 +51,10 @@ vi.mock("../fetchers/io-intelligence", async (importOriginal) => ({ ...(await importOriginal()), getIOIntelligenceModels: fetchers.getIOIntelligenceModels, })) +vi.mock("../fetchers/standard-compute", async (importOriginal) => ({ + ...(await importOriginal()), + getStandardComputeModels: fetchers.getStandardComputeModels, +})) vi.mock("../fetchers/kenari", async (importOriginal) => ({ ...(await importOriginal()), getKenariModels: fetchers.getKenariModels, @@ -121,6 +126,7 @@ import { OpencodeGoHandler } from "../opencode-go" import { OpenRouterHandler } from "../openrouter" import { PoeHandler } from "../poe" import { RequestyHandler } from "../requesty" +import { StandardComputeHandler } from "../standard-compute" import { UnboundHandler } from "../unbound" import { VercelAiGatewayHandler } from "../vercel-ai-gateway" import { ZooGatewayHandler } from "../zoo-gateway" @@ -164,6 +170,15 @@ const cases: AdapterCase[] = [ forwardsSignal: true, prewarms: false, }, + { + name: "Standard Compute", + create: () => new StandardComputeHandler({ standardComputeApiKey: API_KEY }), + fetcher: "getStandardComputeModels", + scope: { urlScoped: false, keyScoped: true, authScoped: false }, + expectedArgs: [API_KEY], + forwardsSignal: true, + prewarms: false, + }, { name: "Kenari", create: () => new KenariHandler({ kenariApiKey: API_KEY }), diff --git a/src/api/providers/__tests__/standard-compute.spec.ts b/src/api/providers/__tests__/standard-compute.spec.ts new file mode 100644 index 0000000000..39d58a741e --- /dev/null +++ b/src/api/providers/__tests__/standard-compute.spec.ts @@ -0,0 +1,465 @@ +vi.mock("vscode", () => ({ + workspace: { getConfiguration: () => ({ get: (_key: string, defaultValue?: unknown) => defaultValue }) }, +})) + +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" + +import { standardComputeDefaultModelId, providerIdentifiers } from "@roo-code/types" + +import { buildApiHandler } from "../../index" +import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" +import { clearAllMocks } from "../../../test-utils/reset" +import { StandardComputeHandler } from "../standard-compute" +import { getModels } from "../fetchers/modelCache" + +vi.mock("openai") +vi.mock("../fetchers/modelCache", () => ({ + getModels: vi.fn().mockResolvedValue({ + "moonshotai/kimi-k3": { + maxTokens: 8192, + contextWindow: 128000, + supportsImages: false, + supportsPromptCache: false, + }, + }), + getModelsFromCache: vi.fn(), + refreshModels: vi.fn().mockResolvedValue({}), +})) + +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" }] + +/** Flattens every argument of every console.error call into one searchable string. */ +const loggedText = (spy: { mock: { calls: unknown[][] } }) => + spy.mock.calls + .flat() + .map((arg) => (typeof arg === "string" ? arg : JSON.stringify(arg))) + .join("\n") + +describe("StandardComputeHandler", () => { + beforeEach(() => { + clearAllMocks() + vi.mocked(getModels).mockResolvedValue({ + "moonshotai/kimi-k3": { + maxTokens: 8192, + contextWindow: 128000, + supportsImages: false, + supportsPromptCache: false, + }, + }) + mockCreate.mockResolvedValue(asyncStreamFrom([])) + }) + + it("is constructed by the backend provider registry", () => { + expect(buildApiHandler({ apiProvider: providerIdentifiers.standardCompute })).toBeInstanceOf( + StandardComputeHandler, + ) + }) + + it("resolves the default model when none is configured", async () => { + const handler = new StandardComputeHandler({}) + await collectStream(handler.createMessage("system", messages)) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: standardComputeDefaultModelId }), + expect.objectContaining({ signal: undefined }), + ) + }) + + it("uses the configured model id and streams text, reasoning, and tool calls", async () => { + mockCreate.mockResolvedValue( + asyncStreamFrom([ + { choices: [{ delta: { content: "answer" } }] }, + { choices: [{ delta: { reasoning_content: "thinking" } }] }, + { + choices: [ + { + delta: { + tool_calls: [ + { index: 0, id: "call-1", function: { name: "read_file", arguments: '{"path":' } }, + ], + }, + }, + ], + }, + ]), + ) + const chunks = await collectStream( + new StandardComputeHandler({ + standardComputeModelId: "z-ai/glm-5.3", + }).createMessage("sys", messages), + ) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: "z-ai/glm-5.3" }), + expect.objectContaining({ signal: undefined }), + ) + expect(chunks).toEqual([ + { type: "text", text: "answer" }, + { type: "reasoning", text: "thinking" }, + { type: "tool_call_partial", index: 0, id: "call-1", name: "read_file", arguments: '{"path":' }, + ]) + }) + + it("forwards native tools, usage streaming, max_tokens, 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 StandardComputeHandler({ + standardComputeModelId: "moonshotai/kimi-k3", + modelTemperature: 0.7, + }) + await collectStream( + handler.createMessage("sys", messages, { + taskId: "task", + tools, + tool_choice: "required", + parallelToolCalls: false, + abortSignal: signal, + }), + ) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: "moonshotai/kimi-k3", + messages: [ + { role: "system", content: "sys" }, + { role: "user", content: "Hello" }, + ], + stream: true, + stream_options: { include_usage: true }, + max_tokens: 8192, + temperature: 0.7, + 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("maps usage from the final chunk", async () => { + mockCreate.mockResolvedValue( + asyncStreamFrom([ + { + choices: [], + usage: { + prompt_tokens: 20, + completion_tokens: 10, + prompt_tokens_details: { cached_tokens: 5 }, + }, + }, + ]), + ) + expect( + await collectStream( + new StandardComputeHandler({ standardComputeModelId: "moonshotai/kimi-k3" }).createMessage( + "sys", + messages, + ), + ), + ).toEqual([ + { + type: "usage", + inputTokens: 20, + outputTokens: 10, + cacheReadTokens: 5, + }, + ]) + }) + + it("preserves a zero cache-read count instead of coercing it away", async () => { + mockCreate.mockResolvedValue( + asyncStreamFrom([ + { + choices: [], + usage: { + prompt_tokens: 20, + completion_tokens: 10, + prompt_tokens_details: { cached_tokens: 0 }, + }, + }, + ]), + ) + expect( + await collectStream( + new StandardComputeHandler({ standardComputeModelId: "moonshotai/kimi-k3" }).createMessage( + "sys", + messages, + ), + ), + ).toEqual([ + { + type: "usage", + inputTokens: 20, + outputTokens: 10, + cacheReadTokens: 0, + }, + ]) + }) + + it("redacts the API key from streaming errors and from the logged error", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + mockCreate.mockRejectedValue(new Error("upstream rejected secret-key")) + const handler = new StandardComputeHandler({ + standardComputeApiKey: "secret-key", + standardComputeModelId: "moonshotai/kimi-k3", + }) + await expect(collectStream(handler.createMessage("sys", messages))).rejects.toMatchObject({ + message: "Standard Compute streaming error: upstream rejected [REDACTED]", + }) + // The error handler logs message + stack before the transformer runs, + // so the key must already be gone from both. + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[Standard Compute] API error:", + expect.objectContaining({ + message: "upstream rejected [REDACTED]", + stack: expect.stringContaining("[REDACTED]"), + }), + ) + expect(loggedText(consoleErrorSpy)).not.toContain("secret-key") + } finally { + consoleErrorSpy.mockRestore() + } + }) + + it("redacts the API key from the SDK's raw error metadata before it is logged", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + // The OpenAI SDK attaches the upstream body as `error.metadata.raw`, + // which the error handler prefers over `message` when logging. + mockCreate.mockRejectedValue( + Object.assign(new Error("Request failed"), { + error: { metadata: { raw: '{"detail":"invalid key secret-key"}' } }, + }), + ) + const handler = new StandardComputeHandler({ + standardComputeApiKey: "secret-key", + standardComputeModelId: "moonshotai/kimi-k3", + }) + await expect(collectStream(handler.createMessage("sys", messages))).rejects.toMatchObject({ + message: 'Standard Compute streaming error: {"detail":"invalid key [REDACTED]"}', + }) + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[Standard Compute] API error:", + expect.objectContaining({ message: '{"detail":"invalid key [REDACTED]"}' }), + ) + expect(loggedText(consoleErrorSpy)).not.toContain("secret-key") + } finally { + consoleErrorSpy.mockRestore() + } + }) + + it("serializes and redacts an object-valued raw error body and keeps the status", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + mockCreate.mockRejectedValue( + Object.assign(new Error("Request failed"), { + status: 401, + error: { metadata: { raw: { detail: "invalid key secret-key" } } }, + }), + ) + const handler = new StandardComputeHandler({ + standardComputeApiKey: "secret-key", + standardComputeModelId: "moonshotai/kimi-k3", + }) + await expect(collectStream(handler.createMessage("sys", messages))).rejects.toMatchObject({ + message: 'Standard Compute streaming error: {"detail":"invalid key [REDACTED]"}', + status: 401, + }) + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[Standard Compute] API error:", + expect.objectContaining({ message: '{"detail":"invalid key [REDACTED]"}', status: 401 }), + ) + expect(loggedText(consoleErrorSpy)).not.toContain("secret-key") + } finally { + consoleErrorSpy.mockRestore() + } + }) + + it("redacts the API key from non-Error rejections and their log line", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + mockCreate.mockRejectedValue("upstream rejected secret-key") + const handler = new StandardComputeHandler({ + standardComputeApiKey: "secret-key", + standardComputeModelId: "moonshotai/kimi-k3", + }) + await expect(collectStream(handler.createMessage("sys", messages))).rejects.toMatchObject({ + message: "Standard Compute streaming error: upstream rejected [REDACTED]", + }) + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[Standard Compute] Non-Error exception:", + "upstream rejected [REDACTED]", + ) + expect(loggedText(consoleErrorSpy)).not.toContain("secret-key") + } finally { + consoleErrorSpy.mockRestore() + } + }) + + it("completePrompt wraps upstream errors without a configured key", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + mockCreate.mockRejectedValue(new Error("boom")) + const handler = new StandardComputeHandler({ standardComputeModelId: "moonshotai/kimi-k3" }) + await expect(handler.completePrompt("ping")).rejects.toMatchObject({ + message: "Standard Compute completion error: boom", + }) + } finally { + consoleErrorSpy.mockRestore() + } + }) + + it("completePrompt sends the prompt as a single user message with the model's max_tokens", async () => { + mockCreate.mockResolvedValue({ + choices: [{ message: { role: "assistant", content: "ok" } }], + }) + const handler = new StandardComputeHandler({ standardComputeModelId: "moonshotai/kimi-k3" }) + expect(await handler.completePrompt("ping")).toBe("ok") + expect(mockCreate).toHaveBeenCalledWith( + { + model: "moonshotai/kimi-k3", + messages: [{ role: "user", content: "ping" }], + max_tokens: 8192, + stream: false, + }, + undefined, + ) + }) + + it("completePrompt returns an empty string when the response has no choices", async () => { + mockCreate.mockResolvedValue({ choices: [] }) + const handler = new StandardComputeHandler({ standardComputeModelId: "moonshotai/kimi-k3" }) + expect(await handler.completePrompt("ping")).toBe("") + }) + + it("completePrompt without options forwards no request options", async () => { + mockCreate.mockResolvedValue({ + choices: [{ message: { role: "assistant", content: "ok" } }], + }) + const handler = new StandardComputeHandler({ standardComputeModelId: "moonshotai/kimi-k3" }) + expect(await handler.completePrompt("ping")).toBe("ok") + // The OpenAI SDK rejects a present-but-undefined `timeout` key, so a bare + // call must not pass request options at all. + expect(mockCreate.mock.calls[0][1]).toBeUndefined() + }) + + it("completePrompt forwards abort and timeout options when provided", async () => { + mockCreate.mockResolvedValue({ + choices: [{ message: { role: "assistant", content: "ok" } }], + }) + const signal = new AbortController().signal + const handler = new StandardComputeHandler({ standardComputeModelId: "moonshotai/kimi-k3" }) + await handler.completePrompt("ping", { abortSignal: signal, timeoutMs: 5_000 }) + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ stream: false }), { signal, timeout: 5_000 }) + }) + + it.each([ + { label: "a whole-number", input: 3000, forwarded: 3000 }, + { label: "a fractional", input: 12.5, forwarded: 13 }, + { label: "an over-cap", input: 2_147_483_648, forwarded: 2_147_483_647 }, + ])("completePrompt forwards $label timeoutMs as $forwarded", async ({ input, forwarded }) => { + mockCreate.mockResolvedValue({ choices: [{ message: { role: "assistant", content: "ok" } }] }) + const handler = new StandardComputeHandler({ standardComputeModelId: "moonshotai/kimi-k3" }) + await handler.completePrompt("ping", { timeoutMs: input }) + expect(mockCreate.mock.calls[0][1]).toEqual({ timeout: forwarded }) + }) + + it.each([0, -5, Number.NaN, Number.POSITIVE_INFINITY])( + "completePrompt does not forward an unusable timeoutMs (%s)", + async (timeoutMs) => { + mockCreate.mockResolvedValue({ choices: [{ message: { role: "assistant", content: "ok" } }] }) + const handler = new StandardComputeHandler({ standardComputeModelId: "moonshotai/kimi-k3" }) + await handler.completePrompt("ping", { timeoutMs }) + expect(mockCreate.mock.calls[0][1]).toBeUndefined() + }, + ) + + it("does not send the OpenAI Compatible custom headers to the fixed endpoint", () => { + new StandardComputeHandler({ + standardComputeApiKey: "test-key", + openAiBaseUrl: "https://other.example.com/v1", + openAiApiKey: "other-key", + openAiHeaders: { "X-Custom-Header": "leaked" }, + }) + expect(vi.mocked(OpenAI)).toHaveBeenCalledTimes(1) + const clientOptions = vi.mocked(OpenAI).mock.calls[0][0] + expect(clientOptions?.baseURL).toBe("https://api.stdcmpt.com/v1") + expect(clientOptions?.apiKey).toBe("test-key") + expect(clientOptions?.defaultHeaders).not.toHaveProperty("X-Custom-Header") + }) + + it("omits temperature for a model that does not support it", async () => { + vi.mocked(getModels).mockResolvedValue({ + "openai/o3-mini": { + maxTokens: 8192, + contextWindow: 128000, + supportsImages: false, + supportsPromptCache: false, + }, + }) + const handler = new StandardComputeHandler({ + standardComputeModelId: "openai/o3-mini", + modelTemperature: 0.7, + }) + + await collectStream(handler.createMessage("system", messages)) + expect(mockCreate).toHaveBeenCalledTimes(1) + expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("temperature") + + mockCreate.mockClear() + mockCreate.mockResolvedValue({ choices: [{ message: { role: "assistant", content: "ok" } }] }) + expect(await handler.completePrompt("ping")).toBe("ok") + expect(mockCreate).toHaveBeenCalledTimes(1) + expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("temperature") + }) + + it("omits temperature when the model metadata does not support it", async () => { + vi.mocked(getModels).mockResolvedValue({ + "moonshotai/kimi-k3": { + maxTokens: 8192, + contextWindow: 128000, + supportsImages: false, + supportsPromptCache: false, + supportsTemperature: false, + }, + }) + const handler = new StandardComputeHandler({ + standardComputeModelId: "moonshotai/kimi-k3", + modelTemperature: 0.7, + }) + + await collectStream(handler.createMessage("system", messages)) + expect(mockCreate).toHaveBeenCalledTimes(1) + expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("temperature") + + mockCreate.mockClear() + mockCreate.mockResolvedValue({ choices: [{ message: { role: "assistant", content: "ok" } }] }) + expect(await handler.completePrompt("ping")).toBe("ok") + expect(mockCreate).toHaveBeenCalledTimes(1) + expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("temperature") + }) + + it("completePrompt forwards the configured temperature", async () => { + mockCreate.mockResolvedValue({ + choices: [{ message: { role: "assistant", content: "ok" } }], + }) + const handler = new StandardComputeHandler({ + standardComputeModelId: "moonshotai/kimi-k3", + modelTemperature: 0.7, + }) + expect(await handler.completePrompt("ping")).toBe("ok") + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ stream: false, temperature: 0.7 }), undefined) + }) +}) diff --git a/src/api/providers/fetchers/__tests__/modelCache.spec.ts b/src/api/providers/fetchers/__tests__/modelCache.spec.ts index 3081b5f3eb..e56a5a459a 100644 --- a/src/api/providers/fetchers/__tests__/modelCache.spec.ts +++ b/src/api/providers/fetchers/__tests__/modelCache.spec.ts @@ -78,6 +78,7 @@ const LITELLM_SCOPE: ModelCacheScope = { urlScoped: true, keyScoped: true, authS const REQUESTY_SCOPE: ModelCacheScope = { urlScoped: true, keyScoped: true, authScoped: false } const NANOGPT_SCOPE: ModelCacheScope = { urlScoped: false, keyScoped: true, authScoped: false } const IOINTELLIGENCE_SCOPE: ModelCacheScope = { urlScoped: false, keyScoped: true, authScoped: false } +const STANDARDCOMPUTE_SCOPE: ModelCacheScope = { urlScoped: false, keyScoped: true, authScoped: false } const ZOO_SCOPE: ModelCacheScope = { urlScoped: true, keyScoped: true, authScoped: true } const MOONSHOT_SCOPE: ModelCacheScope = { urlScoped: true, keyScoped: true, authScoped: false } @@ -86,6 +87,7 @@ const zooGatewayHandler = makeHandler(ZOO_SCOPE, vi.fn()) const DUMMY_REQUESTY_KEY = "requesty-key-for-testing" const DUMMY_IOINTELLIGENCE_KEY = "io-intelligence-key-for-testing" +const DUMMY_STANDARDCOMPUTE_KEY = "standard-compute-key-for-testing" describe("getModels with new GetModelsOptions", () => { beforeEach(() => { @@ -249,6 +251,24 @@ describe("getModels with new GetModelsOptions", () => { expect(result).toEqual(mockModels) }) + it("dispatches Standard Compute with an optional API key", async () => { + const mockModels = { + "moonshotai/kimi-k3": { + maxTokens: 8192, + contextWindow: 128000, + supportsPromptCache: false, + }, + } + const fetchModels = vi.fn().mockResolvedValue(mockModels) + const handler = makeHandler(STANDARDCOMPUTE_SCOPE, fetchModels) + + const options = { provider: providerIdentifiers.standardCompute, apiKey: DUMMY_STANDARDCOMPUTE_KEY } as const + const result = await getModels(options, handler) + + expect(fetchModels).toHaveBeenCalledWith(options, expect.any(AbortSignal)) + expect(result).toEqual(mockModels) + }) + it("handles errors and re-throws them", async () => { const expectedError = new Error("LiteLLM connection failed") const fetchModels = vi.fn().mockRejectedValue(expectedError) @@ -1285,6 +1305,33 @@ describe("IO Intelligence key-scoped cache isolation", () => { }) }) +describe("Standard Compute key-scoped cache isolation", () => { + const standardComputeModels = { + "moonshotai/kimi-k3": { maxTokens: 8_192, contextWindow: 128_000, supportsPromptCache: false }, + } + + let handler: ApiHandler + + beforeEach(() => { + vi.clearAllMocks() + handler = makeHandler(STANDARDCOMPUTE_SCOPE, vi.fn().mockResolvedValue(standardComputeModels)) + }) + + 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.standardCompute }, handler) + await getModels({ provider: providerIdentifiers.standardCompute, apiKey: "sc-key-a" }, handler) + await getModels({ provider: providerIdentifiers.standardCompute, apiKey: "sc-key-b" }, handler) + + const cacheKeys = mockCache.set.mock.calls.map(([key]) => key as string) + expect(new Set(cacheKeys).size).toBe(3) + expect(cacheKeys).toContain("standard-compute") + expect(cacheKeys.every((key) => !key.includes("sc-key-a") && !key.includes("sc-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__/standard-compute.spec.ts b/src/api/providers/fetchers/__tests__/standard-compute.spec.ts new file mode 100644 index 0000000000..a0a807793c --- /dev/null +++ b/src/api/providers/fetchers/__tests__/standard-compute.spec.ts @@ -0,0 +1,224 @@ +import axios from "axios" + +import { + STANDARD_COMPUTE_BASE_URL, + standardComputeDefaultModelId, + standardComputeDefaultModelInfo, +} from "@roo-code/types" + +import { getStandardComputeModels, parseStandardComputeModel } from "../standard-compute" + +vi.mock("axios") + +describe("Standard Compute model fetcher", () => { + beforeEach(() => vi.clearAllMocks()) + + it("requests the catalog with optional Bearer authorization", async () => { + vi.mocked(axios.get).mockResolvedValue({ data: { data: [] } }) + await getStandardComputeModels("key-a") + expect(axios.get).toHaveBeenCalledWith(`${STANDARD_COMPUTE_BASE_URL}/models`, { + headers: { Authorization: "Bearer key-a" }, + timeout: 10_000, + signal: undefined, + }) + }) + + it("supports unauthenticated catalog requests", async () => { + vi.mocked(axios.get).mockResolvedValue({ data: { data: [] } }) + await getStandardComputeModels() + expect(axios.get).toHaveBeenCalledWith(`${STANDARD_COMPUTE_BASE_URL}/models`, { + headers: undefined, + timeout: 10_000, + signal: undefined, + }) + }) + + it("forwards the caller's abort signal to the request", async () => { + vi.mocked(axios.get).mockResolvedValue({ data: { data: [] } }) + const controller = new AbortController() + + await getStandardComputeModels("test-key", { signal: controller.signal }) + + expect(axios.get).toHaveBeenCalledWith(`${STANDARD_COMPUTE_BASE_URL}/models`, { + headers: { Authorization: "Bearer test-key" }, + timeout: 10_000, + signal: controller.signal, + }) + }) + + it("rejects with an AbortError when the signal aborts the pending request", async () => { + const controller = new AbortController() + vi.mocked(axios.get).mockImplementation((_url, config) => { + // Mirror the HTTP client: a pending request rejects when its signal fires. + return new Promise((_resolve, reject) => { + config?.signal?.addEventListener?.("abort", () => reject(new Error("canceled")), { once: true }) + }) + }) + + const fetchPromise = getStandardComputeModels("k", { signal: controller.signal }) + controller.abort() + + await expect(fetchPromise).rejects.toMatchObject({ name: "AbortError" }) + }) + + it("maps catalog metadata for multiple models", async () => { + vi.mocked(axios.get).mockResolvedValue({ + data: { + object: "list", + data: [ + { + id: "vision-model", + object: "model", + created: 0, + owned_by: "StandardCompute", + display_name: "Vision Model", + context_length: 1_048_576, + architecture: { input_modalities: ["text", "image"], output_modalities: ["text"] }, + supported_parameters: ["tools", "tool_choice", "reasoning"], + unknown: "allowed", + }, + { + id: "text-model", + context_length: 262_144, + architecture: { input_modalities: ["text"] }, + supported_parameters: ["tools"], + }, + ], + }, + }) + + const models = await getStandardComputeModels() + expect(Object.keys(models)).toEqual(["vision-model", "text-model"]) + expect(models["vision-model"]).toEqual({ + contextWindow: 1_048_576, + maxTokens: standardComputeDefaultModelInfo.maxTokens, + supportsImages: true, + supportsPromptCache: false, + displayName: "Vision Model", + }) + expect(models["text-model"].supportsImages).toBe(false) + expect(models["text-model"].contextWindow).toBe(262_144) + }) + + it("marks the routed model as image capable when the catalog lists no modalities", () => { + const info = parseStandardComputeModel({ id: standardComputeDefaultModelId, context_length: 1_000_000 }) + expect(info.supportsImages).toBe(true) + expect(info.contextWindow).toBe(1_000_000) + }) + + it("keeps the routed model image capable when its modality list omits images", () => { + const info = parseStandardComputeModel({ + id: standardComputeDefaultModelId, + architecture: { input_modalities: ["text"] }, + }) + expect(info.supportsImages).toBe(true) + }) + + it("skips models whose context window leaves no output budget", async () => { + vi.mocked(axios.get).mockResolvedValue({ + data: { + data: [ + { id: "tiny-1", context_length: 1 }, + { id: "tiny-4", context_length: 4 }, + { id: "smallest-usable", context_length: 5 }, + ], + }, + }) + const models = await getStandardComputeModels() + expect(Object.keys(models)).toEqual(["smallest-usable"]) + expect(models["smallest-usable"]).toMatchObject({ contextWindow: 5, maxTokens: 1 }) + }) + + it("skips malformed records and models without tool support", async () => { + vi.mocked(axios.get).mockResolvedValue({ + data: { + data: [ + { id: "eligible" }, + { missing: "id" }, + { id: "chat-only", supported_parameters: ["response_format"] }, + ], + }, + }) + const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined) + expect(await getStandardComputeModels()).toEqual({ + eligible: { + contextWindow: standardComputeDefaultModelInfo.contextWindow, + maxTokens: standardComputeDefaultModelInfo.maxTokens, + supportsImages: false, + supportsPromptCache: false, + }, + }) + expect(warning).toHaveBeenCalledOnce() + warning.mockRestore() + }) + + it("keeps a hostile catalog id as an own entry instead of polluting the prototype chain", async () => { + vi.mocked(axios.get).mockResolvedValue({ + data: { data: [{ id: "__proto__", context_length: 8_192 }, { id: "constructor" }] }, + }) + const models = await getStandardComputeModels() + expect(Object.getPrototypeOf(models)).toBeNull() + expect(Object.keys(models)).toEqual(["__proto__", "constructor"]) + expect(models["__proto__"]).toMatchObject({ contextWindow: 8_192 }) + expect(({} as Record).contextWindow).toBeUndefined() + }) + + it("falls back to the default context window for a null context_length", () => { + const info = parseStandardComputeModel({ id: "legacy-entry", context_length: null }) + expect(info.contextWindow).toBe(standardComputeDefaultModelInfo.contextWindow) + expect(info.maxTokens).toBe(standardComputeDefaultModelInfo.maxTokens) + }) + + 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 getStandardComputeModels()).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 getStandardComputeModels()).toEqual({}) + consoleError.mockRestore() + }, + ) + + it("rejects invalid numeric metadata without leaking the API key in errors", async () => { + vi.mocked(axios.get) + .mockResolvedValueOnce({ + data: { + data: [ + { id: "valid", context_length: 8_192 }, + { id: "invalid-context", context_length: -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 getStandardComputeModels("secret-key")).toEqual({ + valid: expect.objectContaining({ contextWindow: 8_192, maxTokens: 1_638 }), + }) + expect(await getStandardComputeModels("secret-key")).toEqual({}) + expect(consoleError).toHaveBeenLastCalledWith( + "Error fetching Standard Compute models: upstream rejected [REDACTED]", + ) + + warning.mockRestore() + consoleError.mockRestore() + }) + + it("does not invent absent optional metadata", () => { + const info = parseStandardComputeModel({ id: "minimal" }) + expect(info).toEqual({ + contextWindow: standardComputeDefaultModelInfo.contextWindow, + maxTokens: standardComputeDefaultModelInfo.maxTokens, + supportsImages: false, + supportsPromptCache: false, + }) + }) +}) diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 19ba059da5..e192e71cb0 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -505,6 +505,10 @@ export async function initializeModelCacheRefresh(buildHandler: (provider: Route provider: providerIdentifiers.ioIntelligence, options: { provider: providerIdentifiers.ioIntelligence }, }, + { + provider: providerIdentifiers.standardCompute, + options: { provider: providerIdentifiers.standardCompute }, + }, ] // Refresh each provider in background (fire and forget) diff --git a/src/api/providers/fetchers/standard-compute.ts b/src/api/providers/fetchers/standard-compute.ts new file mode 100644 index 0000000000..6db761283d --- /dev/null +++ b/src/api/providers/fetchers/standard-compute.ts @@ -0,0 +1,114 @@ +import axios from "axios" +import { z } from "zod" + +import { + STANDARD_COMPUTE_BASE_URL, + standardComputeDefaultModelId, + standardComputeDefaultModelInfo, + type ModelInfo, + type ModelRecord, +} from "@roo-code/types" + +import { throwIfAborted } from "../utils/abort-signal" + +const standardComputeModelSchema = z.object({ + id: z.string().min(1), + display_name: z.string().optional(), + context_length: z.number().int().positive().nullish(), + architecture: z + .object({ + input_modalities: z.array(z.string()).optional(), + }) + .nullish(), + supported_parameters: z.array(z.string()).optional(), +}) + +export type StandardComputeModel = 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 standardComputeModelsResponseSchema = z.object({ + object: z.string().optional(), + data: z.array(z.unknown()), +}) + +export const parseStandardComputeModel = (model: StandardComputeModel): ModelInfo => { + const contextWindow = model.context_length ?? standardComputeDefaultModelInfo.contextWindow + return { + // Keep the output budget within 20% of the context window, matching the + // reservation used by context management, so small-window models still fit. + maxTokens: Math.min(standardComputeDefaultModelInfo.maxTokens ?? Infinity, Math.floor(contextWindow * 0.2)), + contextWindow, + // The routed model sends image requests to a vision model, so it accepts + // images although the catalog entry lists no modalities. + supportsImages: + model.id === standardComputeDefaultModelId || + (model.architecture?.input_modalities?.includes("image") ?? false), + supportsPromptCache: false, + // Catalog strings are remote and ModelInfo.description is rendered as + // Markdown, so only the plain-text display name is copied. + ...(model.display_name !== undefined ? { displayName: model.display_name } : {}), + } +} + +/** + * Fetches the public Standard Compute model catalog. + * + * The catalog can be listed without an API key. A Bearer key is forwarded + * when present, so the cache stays scoped to the key. + * + * @param apiKey - Optional Bearer token. + * @param opts - Optional per-request controls; `signal` cancels the in-flight request. + */ +export async function getStandardComputeModels(apiKey?: string, opts?: { signal?: AbortSignal }): Promise { + try { + const response = await axios.get(`${STANDARD_COMPUTE_BASE_URL}/models`, { + headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined, + timeout: 10_000, + signal: opts?.signal, + }) + const responseResult = standardComputeModelsResponseSchema.safeParse(response.data) + if (!responseResult.success) { + console.warn("Standard Compute models response did not match the expected top-level schema") + return {} + } + + // Use null-prototype object to prevent prototype pollution + const models: ModelRecord = Object.create(null) + for (const rawModel of responseResult.data.data) { + const modelResult = standardComputeModelSchema.safeParse(rawModel) + if (!modelResult.success) { + console.warn("Skipping invalid Standard Compute model entry") + continue + } + + // Zoo Code is agentic-first: a model whose supported_parameters list + // omits "tools" is skipped. An absent list remains eligible. + const supportedParameters = modelResult.data.supported_parameters + if (supportedParameters !== undefined && !supportedParameters.includes("tools")) { + continue + } + + const modelInfo = parseStandardComputeModel(modelResult.data) + // A context window too small to leave any output budget cannot + // produce a useful completion, so the entry is not advertised. + if ((modelInfo.maxTokens ?? 0) < 1) { + continue + } + + models[modelResult.data.id] = modelInfo + } + + return models + } catch (error) { + // Surface cancellation as a rejection: logging and returning here would + // present an aborted fetch to callers as a successful (empty) catalog. + throwIfAborted(opts?.signal) + + console.error(`Error fetching Standard Compute models: ${getSafeErrorMessage(error, apiKey)}`) + return {} + } +} diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index e9beb183e2..8757440ccc 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -31,6 +31,7 @@ export { VercelAiGatewayHandler } from "./vercel-ai-gateway" export { OpencodeGoHandler } from "./opencode-go" export { KenariHandler } from "./kenari" export { IOIntelligenceHandler } from "./io-intelligence" +export { StandardComputeHandler } from "./standard-compute" export { NanoGptHandler } from "./nanogpt" export { ZooGatewayHandler } from "./zoo-gateway" export { MiniMaxHandler } from "./minimax" diff --git a/src/api/providers/standard-compute.ts b/src/api/providers/standard-compute.ts new file mode 100644 index 0000000000..ae5a6c5eff --- /dev/null +++ b/src/api/providers/standard-compute.ts @@ -0,0 +1,223 @@ +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" + +import { + STANDARD_COMPUTE_BASE_URL, + standardComputeDefaultModelId, + standardComputeDefaultModelInfo, + providerIdentifiers, + type ModelRecord, +} from "@roo-code/types" + +import type { ApiHandlerOptions, GetModelsOptions } from "../../shared/api" + +import type { ApiStream } from "../transform/stream" +import { convertToOpenAiMessages } from "../transform/openai-format" +import type { + ApiHandlerCreateMessageMetadata, + CompletePromptOptions, + ModelCacheScope, + SingleCompletionHandler, +} from "../index" +import { RouterProvider } from "./router-provider" +import { getStandardComputeModels } from "./fetchers/standard-compute" +import { handleProviderError } from "./utils/error-handler" +import { extractReasoningFromDelta } from "./utils/extract-reasoning" +import { hasUsableTimeout, toSdkTimeoutMs } from "./utils/abort-signal" + +/** + * Standard Compute provider. + * + * OpenAI-compatible Chat Completions endpoint: + * https://api.stdcmpt.com/v1/chat/completions + * + * The default `StandardCompute` model is routed: the router picks a model from + * the models that the user enabled, by the user's quality preference, and uses + * cheaper models for simple steps. Image requests go to a vision model. Pinned + * model ids are resolved dynamically from the public /models endpoint. + * Supports text generation, reasoning content, tool calls, and non-streaming + * prompt completion. + */ +export class StandardComputeHandler extends RouterProvider implements SingleCompletionHandler { + /** Creates a new handler bound to the user's API key and selected model. */ + constructor(options: ApiHandlerOptions) { + super({ + // The endpoint is fixed, so the custom headers configured for the + // OpenAI Compatible provider must not be sent to it. + options: { ...options, openAiHeaders: undefined }, + name: providerIdentifiers.standardCompute, + baseURL: STANDARD_COMPUTE_BASE_URL, + apiKey: options.standardComputeApiKey, + modelId: options.standardComputeModelId, + defaultModelId: standardComputeDefaultModelId, + defaultModelInfo: standardComputeDefaultModelInfo, + }) + } + + override getModelCacheScope(): ModelCacheScope { + // Public catalog. The key is forwarded, so the cache stays key-scoped. + return { urlScoped: false, keyScoped: true, authScoped: false } + } + + async fetchModels(options: GetModelsOptions, signal?: AbortSignal): Promise { + const fetchOpts: [] | [{ signal: AbortSignal }] = signal ? [{ signal }] : [] + return getStandardComputeModels(options.apiKey, ...fetchOpts) + } + + private createSafeError(operation: string, error: unknown): Error { + const apiKey = this.options.standardComputeApiKey + const redact = (text: string) => (apiKey ? text.replaceAll(apiKey, "[REDACTED]") : text) + + // handleProviderError logs the message, stack and the SDK's raw error + // metadata before it applies the transformer, so the key has to be + // scrubbed from the error itself first. + if (error instanceof Error) { + error.message = redact(error.message) + if (error.stack) { + error.stack = redact(error.stack) + } + // handleProviderError calls string methods on this field, so an + // object-valued upstream body is serialized before it is redacted. + const metadata = (error as { error?: { metadata?: { raw?: unknown } } }).error?.metadata + if (metadata && metadata.raw !== undefined && metadata.raw !== null) { + const raw = metadata.raw + metadata.raw = redact(typeof raw === "string" ? raw : (JSON.stringify(raw) ?? String(raw))) + } + } + const safeError = error instanceof Error ? error : redact(String(error)) + + return handleProviderError(safeError, "Standard Compute", { + messagePrefix: operation, + // The transformer input can also come from `error.error.metadata.raw`. + messageTransformer: (message) => `Standard Compute ${operation} error: ${redact(message)}`, + }) + } + + /** + * Streams a chat completion response, yielding typed chunks for text, + * reasoning, partial tool calls, and token usage. + */ + override async *createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + const { id: modelId, info } = await this.fetchModel() + + const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ + { role: "system", content: systemPrompt }, + ...convertToOpenAiMessages(messages), + ] + + const body: OpenAI.Chat.ChatCompletionCreateParams = { + model: modelId, + messages: openAiMessages, + max_tokens: info.maxTokens, + stream: true, + stream_options: { include_usage: true }, + tools: this.convertToolsForOpenAI(metadata?.tools), + tool_choice: metadata?.tool_choice, + parallel_tool_calls: metadata?.parallelToolCalls ?? true, + } + + if ( + this.options.modelTemperature !== undefined && + info.supportsTemperature !== false && + this.supportsTemperature(modelId) + ) { + body.temperature = this.options.modelTemperature + } + + 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 + + // Reasoning models stream reasoning via + // reasoning_content with a `reasoning` field fallback. + const reasoningText = extractReasoningFromDelta(delta) + if (reasoningText) { + yield { type: "reasoning", text: reasoningText } + } + + if (delta?.content) { + yield { type: "text", text: delta.content } + } + + // Emit raw tool call chunks - NativeToolCallParser handles state management. + if (delta?.tool_calls) { + 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 { + type: "usage", + inputTokens: chunk.usage.prompt_tokens || 0, + outputTokens: chunk.usage.completion_tokens || 0, + cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens ?? undefined, + } + } + } + } catch (error) { + throw this.createSafeError("streaming", error) + } + } + + /** + * Performs a non-streaming chat completion and returns the full response text. + * + * @param prompt - The user prompt to send as a single user message. + * @returns The model's reply text, or an empty string if no content is returned. + * @throws Error with an Standard Compute prefix if the request fails. + */ + async completePrompt(prompt: string, options?: CompletePromptOptions): Promise { + const { id: modelId, info } = await this.fetchModel() + + try { + const requestOptions: OpenAI.Chat.ChatCompletionCreateParams = { + model: modelId, + messages: [{ role: "user", content: prompt }], + max_tokens: info.maxTokens, + stream: false, + } + + if ( + this.options.modelTemperature !== undefined && + info.supportsTemperature !== false && + this.supportsTemperature(modelId) + ) { + requestOptions.temperature = this.options.modelTemperature + } + + // The OpenAI SDK validates a present-but-undefined `timeout` key, so + // request options are only forwarded when they carry a real value. + const completionOptions: OpenAI.RequestOptions = {} + if (options?.abortSignal !== undefined) { + completionOptions.signal = options.abortSignal + } + const timeoutMs = options?.timeoutMs + if (hasUsableTimeout(timeoutMs)) { + completionOptions.timeout = toSdkTimeoutMs(timeoutMs) + } + const hasCompletionOptions = + completionOptions.signal !== undefined || completionOptions.timeout !== undefined + + const response = await this.client.chat.completions.create( + requestOptions, + hasCompletionOptions ? completionOptions : undefined, + ) + return response.choices[0]?.message.content || "" + } catch (error) { + throw this.createSafeError("completion", error) + } + } +} diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index 5d78e16351..5e3151c678 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -758,6 +758,22 @@ describe("ContextProxy", () => { }) }) + it("stores and returns the complete Standard Compute configuration across secret and global state", async () => { + await proxy.setProviderSettings({ + apiProvider: providerIdentifiers.standardCompute, + standardComputeApiKey: "sc-secret", + standardComputeModelId: "moonshotai/kimi-k3", + }) + + expect(mockSecrets.store).toHaveBeenCalledWith("standardComputeApiKey", "sc-secret") + expect(mockGlobalState.update).toHaveBeenCalledWith("standardComputeModelId", "moonshotai/kimi-k3") + expect(proxy.getProviderSettings()).toMatchObject({ + apiProvider: providerIdentifiers.standardCompute, + standardComputeApiKey: "sc-secret", + standardComputeModelId: "moonshotai/kimi-k3", + }) + }) + it("stores and returns the complete NanoGPT configuration across secret and global state", async () => { await proxy.setProviderSettings({ apiProvider: providerIdentifiers.nanogpt, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 5cb05f62db..61d01a9dfd 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -6770,6 +6770,7 @@ describe("ClineProvider - Router Models", () => { "opencode-go": mockModels, kenari: mockModels, "io-intelligence": mockModels, + "standard-compute": mockModels, nanogpt: mockModels, "kimi-code": {}, }, @@ -6806,6 +6807,7 @@ describe("ClineProvider - Router Models", () => { .mockResolvedValueOnce(mockModels) // opencode-go (public endpoint) .mockResolvedValueOnce(mockModels) // kenari (public endpoint) .mockResolvedValueOnce(mockModels) // io-intelligence (public endpoint) + .mockResolvedValueOnce(mockModels) // standard-compute (public endpoint) .mockResolvedValueOnce(mockModels) // nanogpt (public endpoint) await messageHandler({ type: "requestRouterModels" }) @@ -6828,6 +6830,7 @@ describe("ClineProvider - Router Models", () => { "opencode-go": mockModels, kenari: mockModels, "io-intelligence": mockModels, + "standard-compute": mockModels, nanogpt: mockModels, "kimi-code": {}, }, @@ -6934,6 +6937,7 @@ describe("ClineProvider - Router Models", () => { "opencode-go": mockModels, kenari: mockModels, "io-intelligence": mockModels, + "standard-compute": mockModels, nanogpt: mockModels, "kimi-code": {}, }, diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index bf50f08786..8a813a4be1 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -1186,6 +1186,11 @@ describe("webviewMessageHandler - requestRouterModels", () => { { provider: providerIdentifiers.ioIntelligence, apiKey: undefined }, expect.anything(), ) + // Standard Compute's /models catalog is public and may optionally be scoped by a key. + expect(mockGetModels).toHaveBeenCalledWith( + { provider: providerIdentifiers.standardCompute, apiKey: undefined }, + expect.anything(), + ) // NanoGPT's detailed catalog is public and may optionally be scoped by a key. expect(mockGetModels).toHaveBeenCalledWith( { provider: providerIdentifiers.nanogpt, apiKey: undefined }, @@ -1210,6 +1215,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { "opencode-go": mockModels, kenari: mockModels, "io-intelligence": mockModels, + "standard-compute": mockModels, nanogpt: mockModels, "kimi-code": {}, }, @@ -1485,6 +1491,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { "opencode-go": mockModels, kenari: mockModels, "io-intelligence": mockModels, + "standard-compute": mockModels, nanogpt: mockModels, "kimi-code": {}, }, @@ -1513,6 +1520,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { .mockResolvedValueOnce(mockModels) // opencode-go .mockResolvedValueOnce(mockModels) // kenari .mockResolvedValueOnce(mockModels) // io-intelligence + .mockResolvedValueOnce(mockModels) // standard-compute .mockResolvedValueOnce(mockModels) // nanogpt await webviewMessageHandler(mockClineProvider, { @@ -1552,6 +1560,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { "opencode-go": mockModels, kenari: mockModels, "io-intelligence": mockModels, + "standard-compute": mockModels, nanogpt: mockModels, "kimi-code": {}, }, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 4adb29264b..14def29da7 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1200,6 +1200,7 @@ export const webviewMessageHandler = async ( [providerIdentifiers.opencodeGo]: {}, [providerIdentifiers.kenari]: {}, [providerIdentifiers.ioIntelligence]: {}, + [providerIdentifiers.standardCompute]: {}, [providerIdentifiers.nanogpt]: {}, [providerIdentifiers.kimiCode]: {}, } @@ -1398,6 +1399,23 @@ export const webviewMessageHandler = async ( options: { provider: providerIdentifiers.ioIntelligence, apiKey: ioIntelligenceApiKey }, }) + // Standard Compute's /models catalog is public; an optional key can scope + // the visible model set. Prefer an explicitly supplied unsaved key. + const standardComputeApiKey = + message?.values?.standardComputeApiKey ?? apiConfiguration.standardComputeApiKey + if (message?.values?.standardComputeApiKey !== undefined) { + await flushModels( + { provider: providerIdentifiers.standardCompute, apiKey: standardComputeApiKey }, + handlerFor(apiConfiguration, providerIdentifiers.standardCompute), + true, + ) + } + + candidates.push({ + key: providerIdentifiers.standardCompute, + options: { provider: providerIdentifiers.standardCompute, apiKey: standardComputeApiKey }, + }) + // 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. diff --git a/src/shared/__tests__/ProfileValidator.spec.ts b/src/shared/__tests__/ProfileValidator.spec.ts index 6e8f404872..5944677a55 100644 --- a/src/shared/__tests__/ProfileValidator.spec.ts +++ b/src/shared/__tests__/ProfileValidator.spec.ts @@ -32,6 +32,7 @@ describe("ProfileValidator", () => { ["requesty", { requestyModelId: "model" }], ["unbound", { unboundModelId: "model" }], ["ioIntelligence", { ioIntelligenceModelId: "model" }], + ["standardCompute", { standardComputeModelId: "model" }], ["poe", { apiModelId: "model" }], ["moonshot", { apiModelId: "model" }], ["kimiCode", { apiModelId: "model" }], @@ -321,6 +322,25 @@ describe("ProfileValidator", () => { ).toBe(false) }) + // Test for standard-compute provider which uses standardComputeModelId + it(`should extract standardComputeModelId for standard-compute provider`, () => { + const allowList: OrganizationAllowList = { + allowAll: false, + providers: { + [providerIdentifiers.standardCompute]: { allowAll: false, models: ["test-model"] }, + }, + } + const profile: ProviderSettings = { + apiProvider: providerIdentifiers.standardCompute, + standardComputeModelId: "test-model", + } + + expect(ProfileValidator.isProfileAllowed(profile, allowList)).toBe(true) + expect( + ProfileValidator.isProfileAllowed({ ...profile, standardComputeModelId: "other-model" }, allowList), + ).toBe(false) + }) + it("should extract vsCodeLmModelSelector.id for vscode-lm provider", () => { const allowList: OrganizationAllowList = { allowAll: false, diff --git a/src/shared/api.ts b/src/shared/api.ts index d8cd547fdb..041639e85c 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -185,6 +185,7 @@ const _dynamicProviderExtras = { [providerIdentifiers.opencodeGo]: {} as { apiKey?: string }, [providerIdentifiers.kenari]: {} as { apiKey?: string }, [providerIdentifiers.ioIntelligence]: {} as { apiKey?: string }, + [providerIdentifiers.standardCompute]: {} as { apiKey?: string }, [providerIdentifiers.nanogpt]: {} as { apiKey?: string }, [providerIdentifiers.kimiCode]: {} 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 6e22859618..931f436718 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -87,6 +87,7 @@ import { OpenCodeGo, Kenari, IOIntelligence, + StandardCompute, NanoGPT, ZooGateway, MiniMax, @@ -720,6 +721,17 @@ const ApiOptions = ({ /> )} + {selectedProvider === providerIdentifiers.standardCompute && ( + + )} + {selectedProvider === providerIdentifiers.nanogpt && ( { OpenCodeGo: provider("provider-opencode-go"), Kenari: provider("provider-kenari"), IOIntelligence: provider("provider-io-intelligence"), + StandardCompute: provider("provider-standard-compute"), NanoGPT: provider("provider-nanogpt"), ZooGateway: provider("provider-zoo-gateway"), MiniMax: provider("provider-minimax"), @@ -380,6 +381,7 @@ describe("ApiOptions interactions", () => { providerIdentifiers.opencodeGo, providerIdentifiers.kenari, providerIdentifiers.ioIntelligence, + providerIdentifiers.standardCompute, providerIdentifiers.nanogpt, providerIdentifiers.zooGateway, providerIdentifiers.fireworks, diff --git a/webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx b/webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx index b68bee99e6..65b34166a2 100644 --- a/webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx @@ -225,6 +225,7 @@ describe("ApiOptions Provider Filtering", () => { expect(providerValues).toContain("lmstudio") expect(providerValues).toContain("litellm") expect(providerValues).toContain("io-intelligence") + expect(providerValues).toContain("standard-compute") expect(providerValues).toContain("requesty") }) diff --git a/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx b/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx index 4738d0d887..1eb4e5c713 100644 --- a/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx @@ -498,6 +498,7 @@ describe("ApiOptions", () => { expect(optionTexts).toContain("Anthropic") expect(optionTexts).toContain("NanoGPT") expect(optionTexts).toContain("IO Intelligence") + expect(optionTexts).toContain("Standard Compute") // 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/constants.ts b/webview-ui/src/components/settings/constants.ts index 8590e536fe..6ed0fa7654 100644 --- a/webview-ui/src/components/settings/constants.ts +++ b/webview-ui/src/components/settings/constants.ts @@ -72,6 +72,7 @@ export const PROVIDERS: Array<{ value: string; label: string; proxy: boolean }> { value: providerIdentifiers.opencodeGo, label: "Opencode Go", proxy: false }, { value: providerIdentifiers.kenari, label: "Kenari", proxy: false }, { value: providerIdentifiers.ioIntelligence, label: "IO Intelligence", proxy: false }, + { value: providerIdentifiers.standardCompute, label: "Standard Compute", proxy: false }, { value: providerIdentifiers.nanogpt, label: "NanoGPT", proxy: false }, { value: providerIdentifiers.zooGateway, label: "Zoo Gateway", proxy: false }, { value: providerIdentifiers.minimax, label: "MiniMax", proxy: false }, diff --git a/webview-ui/src/components/settings/providers/StandardCompute.tsx b/webview-ui/src/components/settings/providers/StandardCompute.tsx new file mode 100644 index 0000000000..48c7eb5481 --- /dev/null +++ b/webview-ui/src/components/settings/providers/StandardCompute.tsx @@ -0,0 +1,102 @@ +import { useCallback } from "react" +import { useDebounce } from "react-use" +import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" + +import { + type OrganizationAllowList, + type ProviderSettings, + type RouterModels, + standardComputeDefaultModelId, + providerIdentifiers, + RouterModelsMessageType, +} from "@roo-code/types" + +import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { vscode } from "@src/utils/vscode" + +import { ModelPicker } from "../ModelPicker" +import { inputEventTransform } from "../transforms" + +type StandardComputeProps = { + apiConfiguration: ProviderSettings + setApiConfigurationField: (field: K, value: ProviderSettings[K]) => void + routerModels?: RouterModels + organizationAllowList: OrganizationAllowList + modelValidationError?: string + simplifySettings?: boolean +} + +export const StandardCompute = ({ + apiConfiguration, + setApiConfigurationField, + routerModels, + organizationAllowList, + modelValidationError, + simplifySettings, +}: StandardComputeProps) => { + const { t } = useAppTranslation() + + const handleInputChange = useCallback( + ( + field: K, + transform: (event: E) => ProviderSettings[K] = inputEventTransform, + ) => + (event: E | Event) => { + setApiConfigurationField(field, transform(event as E)) + }, + [setApiConfigurationField], + ) + + // Debounced model refresh, only executed 250ms after the user stops + // typing the key (same cadence as the provider refreshes in ApiOptions), + // so each keystroke does not trigger a catalog request. + useDebounce( + () => { + vscode.postMessage({ + type: RouterModelsMessageType.requestRouterModels, + values: { + provider: providerIdentifiers.standardCompute, + standardComputeApiKey: apiConfiguration.standardComputeApiKey, + }, + }) + }, + 250, + [apiConfiguration.standardComputeApiKey], + ) + + return ( + <> + + + +
+ {t("settings:providers.apiKeyStorageNotice")} +
+ {!apiConfiguration.standardComputeApiKey && ( + + {t("settings:providers.standardCompute.getApiKey")} + + )} + + + + ) +} diff --git a/webview-ui/src/components/settings/providers/__tests__/IOIntelligence.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/IOIntelligence.spec.tsx index ae6f7bc63a..ade652744b 100644 --- a/webview-ui/src/components/settings/providers/__tests__/IOIntelligence.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/IOIntelligence.spec.tsx @@ -82,6 +82,7 @@ describe("IOIntelligence", () => { "io-intelligence": { "meta-llama/Llama-3.3-70B-Instruct": { contextWindow: 128000, maxTokens: 8192, supportsPromptCache: false }, }, + "standard-compute": {}, "kimi-code": {}, ollama: {}, lmstudio: {}, diff --git a/webview-ui/src/components/settings/providers/__tests__/NanoGPT.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/NanoGPT.spec.tsx index 698fa90e29..ed153f5fb8 100644 --- a/webview-ui/src/components/settings/providers/__tests__/NanoGPT.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/NanoGPT.spec.tsx @@ -104,6 +104,7 @@ describe("NanoGPT", () => { kenari: {}, nanogpt: { "openai/test": { contextWindow: 1, maxTokens: 1, supportsPromptCache: false } }, "io-intelligence": {}, + "standard-compute": {}, "kimi-code": {}, ollama: {}, lmstudio: {}, diff --git a/webview-ui/src/components/settings/providers/__tests__/StandardCompute.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/StandardCompute.spec.tsx new file mode 100644 index 0000000000..3d9f15506b --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/StandardCompute.spec.tsx @@ -0,0 +1,185 @@ +import { act, fireEvent, render, screen } from "@testing-library/react" + +import { + type OrganizationAllowList, + type ProviderSettings, + type RouterModels, + standardComputeDefaultModelId, + providerIdentifiers, + RouterModelsMessageType, +} from "@roo-code/types" + +import { StandardCompute } from "../StandardCompute" + +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} + +
+ ), +})) + +vi.mock("@src/components/common/VSCodeButtonLink", () => ({ + VSCodeButtonLink: ({ children, href }: React.ComponentProps<"a">) => ( + + {children} + + ), +})) + +vi.mock("../../ModelPicker", () => ({ + ModelPicker: ({ + defaultModelId, + models, + modelIdKey, + serviceName, + }: { + defaultModelId: string + models: object + modelIdKey: string + serviceName: string + }) => ( +
+ ), +})) + +describe("StandardCompute", () => { + 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: {}, + "io-intelligence": {}, + "standard-compute": { + "moonshotai/kimi-k3": { contextWindow: 128000, maxTokens: 8192, supportsPromptCache: false }, + }, + "kimi-code": {}, + ollama: {}, + lmstudio: {}, + } + + const renderComponent = (apiConfiguration: ProviderSettings = {}) => + render( + , + ) + + beforeEach(() => vi.clearAllMocks()) + + afterEach(() => vi.useRealTimers()) + + it("renders the secret key input, CTA, and dynamic model picker", () => { + renderComponent({ standardComputeApiKey: "stored" }) + + expect(screen.getByTestId("standard-compute-api-key")).toHaveAttribute("type", "password") + expect(screen.getByText("settings:providers.standardCompute.apiKey")).toBeInTheDocument() + expect(screen.queryByTestId("standard-compute-get-key")).not.toBeInTheDocument() + expect(screen.getByTestId("model-picker")).toHaveAttribute( + "data-default-model-id", + standardComputeDefaultModelId, + ) + expect(screen.getByTestId("model-picker")).toHaveAttribute("data-model-id-key", "standardComputeModelId") + expect(screen.getByTestId("model-picker")).toHaveAttribute("data-model-count", "1") + expect(screen.getByText("settings:providers.apiKeyStorageNotice")).toBeInTheDocument() + }) + + it("shows the get-key CTA only when no key is configured", () => { + renderComponent({}) + + expect(screen.getByTestId("standard-compute-get-key")).toHaveAttribute( + "href", + "https://standardcompute.com/dashboard", + ) + expect(screen.getByText("settings:providers.standardCompute.getApiKey")).toBeInTheDocument() + }) + + it("updates the cached key with exact values", () => { + renderComponent({ standardComputeApiKey: "" }) + + fireEvent.input(screen.getByTestId("standard-compute-api-key"), { target: { value: "new-secret" } }) + + expect(setApiConfigurationField).toHaveBeenCalledWith("standardComputeApiKey", "new-secret") + }) + + it("refreshes models with the unsaved cached key 250ms after it stops changing", () => { + vi.useFakeTimers() + const rerenderWithKey = (rerender: ReturnType["rerender"], standardComputeApiKey: string) => + rerender( + , + ) + + const { rerender } = renderComponent({ standardComputeApiKey: "first-key" }) + + act(() => vi.advanceTimersByTime(249)) + expect(postMessageMock).not.toHaveBeenCalled() + act(() => vi.advanceTimersByTime(1)) + expect(postMessageMock).toHaveBeenCalledTimes(1) + expect(postMessageMock).toHaveBeenLastCalledWith({ + type: RouterModelsMessageType.requestRouterModels, + values: { provider: providerIdentifiers.standardCompute, standardComputeApiKey: "first-key" }, + }) + + // Each edit restarts the timer, so intermediate values never reach the host. + rerenderWithKey(rerender, "unsaved-") + act(() => vi.advanceTimersByTime(200)) + rerenderWithKey(rerender, "unsaved-key") + act(() => vi.advanceTimersByTime(249)) + expect(postMessageMock).toHaveBeenCalledTimes(1) + + act(() => vi.advanceTimersByTime(1)) + expect(postMessageMock).toHaveBeenCalledTimes(2) + expect(postMessageMock).toHaveBeenLastCalledWith({ + type: RouterModelsMessageType.requestRouterModels, + values: { provider: providerIdentifiers.standardCompute, standardComputeApiKey: "unsaved-key" }, + }) + }) + + it("drops a pending model refresh when it unmounts", () => { + vi.useFakeTimers() + const { unmount } = renderComponent({ standardComputeApiKey: "first-key" }) + + unmount() + act(() => vi.advanceTimersByTime(250)) + + expect(postMessageMock).not.toHaveBeenCalled() + }) +}) diff --git a/webview-ui/src/components/settings/providers/index.ts b/webview-ui/src/components/settings/providers/index.ts index ee583e563e..e9eb45312c 100644 --- a/webview-ui/src/components/settings/providers/index.ts +++ b/webview-ui/src/components/settings/providers/index.ts @@ -27,6 +27,7 @@ export { VercelAiGateway } from "./VercelAiGateway" export { OpenCodeGo } from "./OpenCodeGo" export { Kenari } from "./Kenari" export { IOIntelligence } from "./IOIntelligence" +export { StandardCompute } from "./StandardCompute" export { NanoGPT } from "./NanoGPT" export { ZooGateway } from "./ZooGateway" export { MiniMax } from "./MiniMax" 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 4342ffdf93..c4688ef08f 100644 --- a/webview-ui/src/components/settings/utils/__tests__/providerModelConfig.spec.ts +++ b/webview-ui/src/components/settings/utils/__tests__/providerModelConfig.spec.ts @@ -4,6 +4,7 @@ import { mainlandZAiDefaultModelId, nanoGptDefaultModelId, providerIdentifiers, + standardComputeDefaultModelId, } from "@roo-code/types" import { @@ -192,6 +193,13 @@ describe("providerModelConfig", () => { default: ioIntelligenceDefaultModelId, }) }) + + it("returns Standard Compute's dynamic model field and fallback", () => { + expect(getProviderModelConfig(providerIdentifiers.standardCompute)).toEqual({ + field: "standardComputeModelId", + default: standardComputeDefaultModelId, + }) + }) }) describe("getProviderDocsSlug", () => { diff --git a/webview-ui/src/components/settings/utils/providerModelConfig.ts b/webview-ui/src/components/settings/utils/providerModelConfig.ts index 1bda52cf6f..2156837df0 100644 --- a/webview-ui/src/components/settings/utils/providerModelConfig.ts +++ b/webview-ui/src/components/settings/utils/providerModelConfig.ts @@ -29,6 +29,7 @@ import { opencodeGoDefaultModelId, kenariDefaultModelId, ioIntelligenceDefaultModelId, + standardComputeDefaultModelId, nanoGptDefaultModelId, zooGatewayDefaultModelId, getZAiModels, @@ -148,6 +149,10 @@ const PROVIDER_MODEL_CONFIG: Partial> field: "ioIntelligenceModelId", default: ioIntelligenceDefaultModelId, }, + [providerIdentifiers.standardCompute]: { + field: "standardComputeModelId", + default: standardComputeDefaultModelId, + }, [providerIdentifiers.nanogpt]: { field: "nanoGptModelId", default: nanoGptDefaultModelId }, [providerIdentifiers.zooGateway]: { field: "zooGatewayModelId", default: zooGatewayDefaultModelId }, [providerIdentifiers.openai]: { field: "openAiModelId" }, 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 4955c3c031..171a1f28d0 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -20,6 +20,8 @@ import { litellmDefaultModelInfo, ioIntelligenceDefaultModelId, ioIntelligenceDefaultModelInfo, + standardComputeDefaultModelId, + standardComputeDefaultModelInfo, kenariDefaultModelId, kenariDefaultModelInfo, nanoGptDefaultModelId, @@ -1385,6 +1387,102 @@ describe("useSelectedModel", () => { }) }) + describe("standard-compute provider", () => { + it("uses dynamic metadata for the configured Standard Compute model", () => { + const dynamicInfo: ModelInfo = { + maxTokens: 8_192, + contextWindow: 131_072, + supportsPromptCache: true, + description: "Dynamic Standard Compute model", + } + mockUseRouterModels.mockReturnValue( + createRouterModelsResult({ "standard-compute": { "z-ai/glm-5.3": dynamicInfo } }), + ) + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + + const { result } = renderHook( + () => + useSelectedModel({ + apiProvider: providerIdentifiers.standardCompute, + standardComputeModelId: "z-ai/glm-5.3", + }), + { wrapper: createWrapper() }, + ) + + expect(result.current.id).toBe("z-ai/glm-5.3") + expect(result.current.info).toEqual(dynamicInfo) + }) + + it("uses Standard Compute's shared fallback when its dynamic catalog is empty", () => { + mockUseRouterModels.mockReturnValue(createRouterModelsResult({ "standard-compute": {} })) + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + + const { result } = renderHook( + () => + useSelectedModel({ + apiProvider: providerIdentifiers.standardCompute, + // standardComputeModelId intentionally omitted + }), + { wrapper: createWrapper() }, + ) + + expect(result.current.id).toBe(standardComputeDefaultModelId) + expect(result.current.info).toEqual(standardComputeDefaultModelInfo) + }) + + it("preserves a configured model ID that is absent from the fetched catalog", () => { + mockUseRouterModels.mockReturnValue( + createRouterModelsResult({ + "standard-compute": { + "moonshotai/kimi-k3": { + maxTokens: 8_192, + contextWindow: 131_072, + supportsPromptCache: false, + }, + }, + }), + ) + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + + const { result } = renderHook( + () => + useSelectedModel({ + apiProvider: providerIdentifiers.standardCompute, + standardComputeModelId: "custom-org/custom-model", + }), + { wrapper: createWrapper() }, + ) + + // The stored id is what requests are sent with, so the picker must show it + // rather than the default; only the metadata falls back. + expect(result.current.id).toBe("custom-org/custom-model") + expect(result.current.info).toEqual(standardComputeDefaultModelInfo) + }) + + it("preserves the configured model ID when the catalog fetch errors", () => { + // A cold /models failure settles the router query with no data. The + // hook must still resolve and keep the configured ID (the handler + // sends requests with it) instead of resetting to the provider default. + mockUseRouterModels.mockReturnValue( + createRouterModelsResult(undefined, { isLoading: false, isError: true }), + ) + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + + const { result } = renderHook( + () => + useSelectedModel({ + apiProvider: providerIdentifiers.standardCompute, + standardComputeModelId: "z-ai/glm-5.3", + }), + { wrapper: createWrapper() }, + ) + + expect(result.current.id).toBe("z-ai/glm-5.3") + expect(result.current.info).toEqual(standardComputeDefaultModelInfo) + expect(result.current.isError).toBe(true) + }) + }) + describe("openai provider", () => { beforeEach(() => { mockUseRouterModels.mockReturnValue(createRouterModelsResult({ openrouter: {}, requesty: {}, litellm: {} })) diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 9eca0cc0f3..134ce2fc50 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -31,6 +31,7 @@ import { opencodeGoDefaultModelInfo, kenariDefaultModelInfo, ioIntelligenceDefaultModelInfo, + standardComputeDefaultModelInfo, nanoGptDefaultModelInfo, BEDROCK_1M_CONTEXT_MODEL_IDS, BEDROCK_DEFAULT_CONTEXT, @@ -126,14 +127,16 @@ export const useSelectedModel = (apiConfiguration: ProviderSettings) => { const needLmStudio = typeof lmStudioModelId !== "undefined" const needOllama = typeof ollamaModelId !== "undefined" - // LiteLLM and IO Intelligence may legitimately have no entry in the router + // LiteLLM, IO Intelligence and Standard Compute may legitimately have no entry in the router // payload (partial listing, failed fetch, renamed deployment) even though // the configured ID is a valid selection, so they only need the fetch to // settle. Other dynamic providers require a populated provider entry // before the selection is resolved. const hasValidRouterData = needRouterModels && dynamicProvider - ? dynamicProvider === providerIdentifiers.litellm || dynamicProvider === providerIdentifiers.ioIntelligence + ? dynamicProvider === providerIdentifiers.litellm || + dynamicProvider === providerIdentifiers.ioIntelligence || + dynamicProvider === providerIdentifiers.standardCompute ? !routerModels.isLoading : routerModels.data && routerModels.data[dynamicProvider] !== undefined && @@ -571,6 +574,15 @@ function getSelectedModel({ const info = routerModels[providerIdentifiers.ioIntelligence]?.[id] ?? ioIntelligenceDefaultModelInfo return { id, info } } + case providerIdentifiers.standardCompute: { + // A configured id is the user's explicit selection (ModelPicker's + // "Use custom model" path stores ids absent from the fetched catalog) + // and the handler sends requests with it, so keep it instead of + // displaying a default model that requests do not use. + const id = apiConfiguration.standardComputeModelId || defaultModelId + const info = routerModels[providerIdentifiers.standardCompute]?.[id] ?? standardComputeDefaultModelInfo + return { id, info } + } case providerIdentifiers.nanogpt: { const id = getValidatedModelId( apiConfiguration.nanoGptModelId, diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 74adb11620..af4c563d60 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -394,6 +394,12 @@ "getApiKey": "Obtenir clau API de IO Intelligence", "model": "Model" }, + "standardCompute": { + "provider": "Standard Compute", + "apiKey": "Clau API de Standard Compute", + "getApiKey": "Obtenir clau API de Standard Compute", + "model": "Model" + }, "nanoGpt": { "provider": "NanoGPT", "apiKey": "Clau API de NanoGPT", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 078c5c6ebf..5f8e77e369 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -394,6 +394,12 @@ "getApiKey": "IO Intelligence API-Schlüssel holen", "model": "Modell" }, + "standardCompute": { + "provider": "Standard Compute", + "apiKey": "Standard Compute API-Schlüssel", + "getApiKey": "Standard Compute API-Schlüssel holen", + "model": "Modell" + }, "nanoGpt": { "provider": "NanoGPT", "apiKey": "NanoGPT API-Schlüssel", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 67a9f146e4..6b4d6b2866 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -467,6 +467,12 @@ "getApiKey": "Get IO Intelligence API Key", "model": "Model" }, + "standardCompute": { + "provider": "Standard Compute", + "apiKey": "Standard Compute API Key", + "getApiKey": "Get Standard Compute API Key", + "model": "Model" + }, "nanoGpt": { "provider": "NanoGPT", "apiKey": "NanoGPT API Key", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 81aeb1744c..5dd58c8f82 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -394,6 +394,12 @@ "getApiKey": "Obtener clave API de IO Intelligence", "model": "Modelo" }, + "standardCompute": { + "provider": "Standard Compute", + "apiKey": "Clave API de Standard Compute", + "getApiKey": "Obtener clave API de Standard Compute", + "model": "Modelo" + }, "nanoGpt": { "provider": "NanoGPT", "apiKey": "Clave API de NanoGPT", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 555b088322..6ff6e98dc6 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -394,6 +394,12 @@ "getApiKey": "Obtenir une clé API IO Intelligence", "model": "Modèle" }, + "standardCompute": { + "provider": "Standard Compute", + "apiKey": "Clé API Standard Compute", + "getApiKey": "Obtenir une clé API Standard Compute", + "model": "Modèle" + }, "nanoGpt": { "provider": "NanoGPT", "apiKey": "Clé API NanoGPT", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 5c33bf5ccb..404a355cf0 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -394,6 +394,12 @@ "getApiKey": "IO Intelligence API कुंजी प्राप्त करें", "model": "मॉडल" }, + "standardCompute": { + "provider": "Standard Compute", + "apiKey": "Standard Compute API कुंजी", + "getApiKey": "Standard Compute API कुंजी प्राप्त करें", + "model": "मॉडल" + }, "nanoGpt": { "provider": "NanoGPT", "apiKey": "NanoGPT API कुंजी", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 62d91e4d06..c2cb117238 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -394,6 +394,12 @@ "getApiKey": "Dapatkan Kunci API IO Intelligence", "model": "Model" }, + "standardCompute": { + "provider": "Standard Compute", + "apiKey": "Kunci API Standard Compute", + "getApiKey": "Dapatkan Kunci API Standard Compute", + "model": "Model" + }, "nanoGpt": { "provider": "NanoGPT", "apiKey": "NanoGPT API Key", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index eb090e3f47..b57d283b31 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -394,6 +394,12 @@ "getApiKey": "Ottieni chiave API IO Intelligence", "model": "Modello" }, + "standardCompute": { + "provider": "Standard Compute", + "apiKey": "Chiave API Standard Compute", + "getApiKey": "Ottieni chiave API Standard Compute", + "model": "Modello" + }, "nanoGpt": { "provider": "NanoGPT", "apiKey": "Chiave API NanoGPT", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index a7939d2e3d..eecb2a9383 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -394,6 +394,12 @@ "getApiKey": "IO Intelligence APIキーを取得", "model": "モデル" }, + "standardCompute": { + "provider": "Standard Compute", + "apiKey": "Standard Compute APIキー", + "getApiKey": "Standard Compute APIキーを取得", + "model": "モデル" + }, "nanoGpt": { "provider": "NanoGPT", "apiKey": "NanoGPT APIキー", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 572141342a..26f93cce6d 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -394,6 +394,12 @@ "getApiKey": "IO Intelligence API 키 받기", "model": "모델" }, + "standardCompute": { + "provider": "Standard Compute", + "apiKey": "Standard Compute API 키", + "getApiKey": "Standard Compute API 키 받기", + "model": "모델" + }, "nanoGpt": { "provider": "NanoGPT", "apiKey": "NanoGPT API 키", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 2103bfd08c..327ee5a4d9 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -394,6 +394,12 @@ "getApiKey": "IO Intelligence API-sleutel ophalen", "model": "Model" }, + "standardCompute": { + "provider": "Standard Compute", + "apiKey": "Standard Compute API-sleutel", + "getApiKey": "Standard Compute API-sleutel ophalen", + "model": "Model" + }, "nanoGpt": { "provider": "NanoGPT", "apiKey": "NanoGPT API-sleutel", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 7a0ea345ec..232daed6de 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -394,6 +394,12 @@ "getApiKey": "Uzyskaj klucz API IO Intelligence", "model": "Model" }, + "standardCompute": { + "provider": "Standard Compute", + "apiKey": "Klucz API Standard Compute", + "getApiKey": "Uzyskaj klucz API Standard Compute", + "model": "Model" + }, "nanoGpt": { "provider": "NanoGPT", "apiKey": "Klucz API NanoGPT", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 3c755f7ee2..e34f33b1a1 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -394,6 +394,12 @@ "getApiKey": "Obter chave de API do IO Intelligence", "model": "Modelo" }, + "standardCompute": { + "provider": "Standard Compute", + "apiKey": "Chave de API da Standard Compute", + "getApiKey": "Obter chave de API da Standard Compute", + "model": "Modelo" + }, "nanoGpt": { "provider": "NanoGPT", "apiKey": "Chave API do NanoGPT", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 69486d9e72..af6f19d13b 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -394,6 +394,12 @@ "getApiKey": "Получить API-ключ IO Intelligence", "model": "Модель" }, + "standardCompute": { + "provider": "Standard Compute", + "apiKey": "API-ключ Standard Compute", + "getApiKey": "Получить API-ключ Standard Compute", + "model": "Модель" + }, "nanoGpt": { "provider": "NanoGPT", "apiKey": "Ключ API NanoGPT", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 760990b0c2..c762cdb297 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -394,6 +394,12 @@ "getApiKey": "IO Intelligence API Anahtarı Al", "model": "Model" }, + "standardCompute": { + "provider": "Standard Compute", + "apiKey": "Standard Compute API Anahtarı", + "getApiKey": "Standard Compute API Anahtarı Al", + "model": "Model" + }, "nanoGpt": { "provider": "NanoGPT", "apiKey": "NanoGPT API Anahtarı", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 07c2596d43..80d55e8990 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -394,6 +394,12 @@ "getApiKey": "Lấy khóa API IO Intelligence", "model": "Mô hình" }, + "standardCompute": { + "provider": "Standard Compute", + "apiKey": "Khóa API Standard Compute", + "getApiKey": "Lấy khóa API Standard Compute", + "model": "Mô hình" + }, "nanoGpt": { "provider": "NanoGPT", "apiKey": "Khóa API NanoGPT", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 12fd5afe63..4549f25063 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -394,6 +394,12 @@ "getApiKey": "获取 IO Intelligence API 密钥", "model": "模型" }, + "standardCompute": { + "provider": "Standard Compute", + "apiKey": "Standard Compute API 密钥", + "getApiKey": "获取 Standard Compute API 密钥", + "model": "模型" + }, "nanoGpt": { "provider": "NanoGPT", "apiKey": "NanoGPT API 密钥", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 6760aa6458..38c6d7f698 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -421,6 +421,12 @@ "getApiKey": "取得 IO Intelligence API 金鑰", "model": "模型" }, + "standardCompute": { + "provider": "Standard Compute", + "apiKey": "Standard Compute API 金鑰", + "getApiKey": "取得 Standard Compute API 金鑰", + "model": "模型" + }, "nanoGpt": { "provider": "NanoGPT", "apiKey": "NanoGPT API 金鑰", diff --git a/webview-ui/src/utils/__tests__/validate.spec.ts b/webview-ui/src/utils/__tests__/validate.spec.ts index bf25faf7f2..d7ebd9693d 100644 --- a/webview-ui/src/utils/__tests__/validate.spec.ts +++ b/webview-ui/src/utils/__tests__/validate.spec.ts @@ -53,6 +53,7 @@ describe("Model Validation Functions", () => { unbound: {}, litellm: {}, "io-intelligence": {}, + "standard-compute": {}, ollama: {}, lmstudio: {}, "vercel-ai-gateway": {}, @@ -458,6 +459,27 @@ describe("Model Validation Functions", () => { }) }) + describe("Standard Compute validation", () => { + it("returns an apiKey error when the Standard Compute API key is unset", () => { + const config: ProviderSettings = { + apiProvider: providerIdentifiers.standardCompute, + standardComputeModelId: "z-ai/glm-5.3", + } + + expect(validateApiConfiguration(config, mockRouterModels)).toBe("settings:validation.apiKey") + }) + + it("accepts a Standard Compute API key and selected model", () => { + const config: ProviderSettings = { + apiProvider: providerIdentifiers.standardCompute, + standardComputeApiKey: "valid-key", + standardComputeModelId: "z-ai/glm-5.3", + } + + expect(validateApiConfiguration(config, mockRouterModels)).toBeUndefined() + }) + }) + describe("NanoGPT validation", () => { it("returns an apiKey error when the NanoGPT API key is missing", () => { const config: ProviderSettings = { diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index 9e4addf7b1..9b3e63e2c0 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -194,6 +194,11 @@ function validateModelsAndKeysProvided( return i18next.t("settings:validation.apiKey") } break + case providerIdentifiers.standardCompute: + if (!apiConfiguration.standardComputeApiKey) { + return i18next.t("settings:validation.apiKey") + } + break case providerIdentifiers.nanogpt: if (!apiConfiguration.nanoGptApiKey) { return i18next.t("settings:validation.apiKey")