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 4347292166..d5822c80d1 100644 --- a/apps/cli/src/lib/utils/__tests__/context-window.test.ts +++ b/apps/cli/src/lib/utils/__tests__/context-window.test.ts @@ -14,6 +14,7 @@ describe("getContextWindow", () => { [providerIdentifiers.vercelAiGateway, "vercelAiGatewayModelId"], [providerIdentifiers.opencodeGo, "opencodeGoModelId"], [providerIdentifiers.kenari, "kenariModelId"], + [providerIdentifiers.ioIntelligence, "ioIntelligenceModelId"], [providerIdentifiers.nanogpt, "nanoGptModelId"], [providerIdentifiers.zooGateway, "zooGatewayModelId"], ] as const)("uses the provider-specific model field for %s", (provider, modelField) => { diff --git a/apps/cli/src/lib/utils/context-window.ts b/apps/cli/src/lib/utils/context-window.ts index b78ab0e5b5..e1ed167309 100644 --- a/apps/cli/src/lib/utils/context-window.ts +++ b/apps/cli/src/lib/utils/context-window.ts @@ -50,6 +50,8 @@ function getModelIdForProvider(config: ProviderSettings): string | undefined { return config.unboundModelId case providerIdentifiers.litellm: return config.litellmModelId + case providerIdentifiers.ioIntelligence: + return config.ioIntelligenceModelId case providerIdentifiers.vercelAiGateway: return config.vercelAiGatewayModelId case providerIdentifiers.opencodeGo: @@ -88,7 +90,6 @@ function getModelIdForProvider(config: ProviderSettings): string | undefined { case retiredProviderIdentifiers.featherless: case retiredProviderIdentifiers.groq: case retiredProviderIdentifiers.huggingface: - case retiredProviderIdentifiers.ioIntelligence: case retiredProviderIdentifiers.roo: case providerIdentifiers.vscodeLm: case providerIdentifiers.fakeAi: diff --git a/packages/types/src/__tests__/io-intelligence.test.ts b/packages/types/src/__tests__/io-intelligence.test.ts new file mode 100644 index 0000000000..ca0e4a4390 --- /dev/null +++ b/packages/types/src/__tests__/io-intelligence.test.ts @@ -0,0 +1,26 @@ +import { + dynamicProviders, + getModelId, + getProviderDefaultModelId, + ioIntelligenceDefaultModelId, + isSecretStateKey, + providerIdentifiers, + providerSettingsSchema, +} from "../index.js" + +describe("IO Intelligence shared contract", () => { + it("registers the stable dynamic-provider identity and default model", () => { + expect(providerIdentifiers.ioIntelligence).toBe("io-intelligence") + expect(dynamicProviders).toContain(providerIdentifiers.ioIntelligence) + expect(getProviderDefaultModelId(providerIdentifiers.ioIntelligence)).toBe(ioIntelligenceDefaultModelId) + }) + + it("classifies the API key as secret and resolves the configured model", () => { + expect(isSecretStateKey("ioIntelligenceApiKey")).toBe(true) + const settings = providerSettingsSchema.parse({ + apiProvider: providerIdentifiers.ioIntelligence, + ioIntelligenceModelId: "model", + }) + expect(getModelId(settings)).toBe("model") + }) +}) diff --git a/packages/types/src/__tests__/provider-identifiers.test.ts b/packages/types/src/__tests__/provider-identifiers.test.ts index d9ba4fb799..b0375578b4 100644 --- a/packages/types/src/__tests__/provider-identifiers.test.ts +++ b/packages/types/src/__tests__/provider-identifiers.test.ts @@ -35,6 +35,7 @@ const expectedProviderIdentifiers = [ "opencode-go", "kenari", "nanogpt", + "io-intelligence", "ollama", "lmstudio", "vscode-lm", @@ -69,7 +70,6 @@ const expectedRetiredProviderIdentifiers = [ "featherless", "groq", "huggingface", - "io-intelligence", "roo", ] /* eslint-enable zoo/no-raw-provider-identifiers */ @@ -111,6 +111,7 @@ describe("provider identifiers", () => { providerIdentifiers.opencodeGo, providerIdentifiers.kenari, providerIdentifiers.nanogpt, + providerIdentifiers.ioIntelligence, 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 a623b24b27..ccd5f33a54 100644 --- a/packages/types/src/__tests__/provider-model-id.test.ts +++ b/packages/types/src/__tests__/provider-model-id.test.ts @@ -15,6 +15,7 @@ const expectedModelIdKeys = [ "opencodeGoModelId", "kenariModelId", "nanoGptModelId", + "ioIntelligenceModelId", "zooGatewayModelId", ] as const diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 692798d00d..48718298ad 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -339,6 +339,7 @@ export const SECRET_STATE_KEYS = [ "vercelAiGatewayApiKey", "opencodeGoApiKey", "kenariApiKey", + "ioIntelligenceApiKey", "nanoGptApiKey", "basetenApiKey", ] as const diff --git a/packages/types/src/provider-identifiers.ts b/packages/types/src/provider-identifiers.ts index fdf507bb6b..f1d32b22bc 100644 --- a/packages/types/src/provider-identifiers.ts +++ b/packages/types/src/provider-identifiers.ts @@ -15,6 +15,7 @@ export const providerIdentifiers = { opencodeGo: "opencode-go", kenari: "kenari", nanogpt: "nanogpt", + ioIntelligence: "io-intelligence", ollama: "ollama", lmstudio: "lmstudio", vscodeLm: "vscode-lm", @@ -52,7 +53,6 @@ export const retiredProviderIdentifiers = { featherless: "featherless", groq: "groq", huggingface: "huggingface", - ioIntelligence: "io-intelligence", roo: "roo", } as const diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 0b898f1b66..5027802326 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -73,6 +73,7 @@ export const dynamicProviders = [ providerIdentifiers.opencodeGo, providerIdentifiers.kenari, providerIdentifiers.nanogpt, + providerIdentifiers.ioIntelligence, providerIdentifiers.kimiCode, ] as const @@ -287,6 +288,7 @@ export const modelIdKeys = [ "opencodeGoModelId", "kenariModelId", "nanoGptModelId", + "ioIntelligenceModelId", "zooGatewayModelId", ] as const satisfies readonly ModelIdKey[] @@ -515,6 +517,11 @@ export const MODELS_BY_PROVIDER: Record< }, [providerIdentifiers.opencodeGo]: { id: providerIdentifiers.opencodeGo, label: "Opencode Go", models: [] }, [providerIdentifiers.kenari]: { id: providerIdentifiers.kenari, label: "Kenari", models: [] }, + [providerIdentifiers.ioIntelligence]: { + id: providerIdentifiers.ioIntelligence, + label: "IO Intelligence", + models: [], + }, [providerIdentifiers.nanogpt]: { id: providerIdentifiers.nanogpt, label: "NanoGPT", models: [] }, [providerIdentifiers.zooGateway]: { id: providerIdentifiers.zooGateway, label: "Zoo Gateway", models: [] }, diff --git a/packages/types/src/provider-settings/index.ts b/packages/types/src/provider-settings/index.ts index 3fe8790af1..609c21ed01 100644 --- a/packages/types/src/provider-settings/index.ts +++ b/packages/types/src/provider-settings/index.ts @@ -30,6 +30,7 @@ import { qwenCodeProviderDefinition } from "./qwen-code.js" import { vercelAiGatewayProviderDefinition } from "./vercel-ai-gateway.js" import { opencodeGoProviderDefinition } from "./opencode-go.js" import { kenariProviderDefinition } from "./kenari.js" +import { ioIntelligenceProviderDefinition } from "./io-intelligence.js" import { nanoGptProviderDefinition } from "./nanogpt.js" import { zooGatewayProviderDefinition } from "./zoo-gateway.js" import { basetenProviderDefinition } from "./baseten.js" @@ -82,6 +83,7 @@ export const providerDefinitionList = [ opencodeGoProviderDefinition, kenariProviderDefinition, nanoGptProviderDefinition, + ioIntelligenceProviderDefinition, zooGatewayProviderDefinition, basetenProviderDefinition, ] as const satisfies readonly ProviderDefinition[] diff --git a/packages/types/src/provider-settings/io-intelligence.ts b/packages/types/src/provider-settings/io-intelligence.ts new file mode 100644 index 0000000000..7198e8c1c1 --- /dev/null +++ b/packages/types/src/provider-settings/io-intelligence.ts @@ -0,0 +1,17 @@ +import { z } from "zod" + +import { providerIdentifiers } from "../provider-identifiers.js" +import { baseProviderSettingsShape, createModelIdAccessor, createProviderDefinition } from "./common.js" + +const IO_INTELLIGENCE_MODEL_ID_FIELD = "ioIntelligenceModelId" + +export const ioIntelligenceProviderDefinition = createProviderDefinition({ + apiProvider: providerIdentifiers.ioIntelligence, + modelIdKey: IO_INTELLIGENCE_MODEL_ID_FIELD, + getModelId: createModelIdAccessor(IO_INTELLIGENCE_MODEL_ID_FIELD), + schema: { + ...baseProviderSettingsShape, + ioIntelligenceApiKey: z.string().optional(), + [IO_INTELLIGENCE_MODEL_ID_FIELD]: z.string().optional(), + }, +}) diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index b26476eaed..09a5e2db6b 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -25,6 +25,7 @@ export * from "./xai.js" export * from "./vercel-ai-gateway.js" export * from "./opencode-go.js" export * from "./kenari.js" +export * from "./io-intelligence.js" export * from "./nanogpt.js" export * from "./kimi-code.js" export * from "./zai.js" @@ -55,6 +56,7 @@ import { xaiDefaultModelId } from "./xai.js" import { vercelAiGatewayDefaultModelId } from "./vercel-ai-gateway.js" import { opencodeGoDefaultModelId } from "./opencode-go.js" import { kenariDefaultModelId } from "./kenari.js" +import { ioIntelligenceDefaultModelId } from "./io-intelligence.js" import { nanoGptDefaultModelId } from "./nanogpt.js" import { kimiCodeDefaultModelId } from "./kimi-code.js" import { internationalZAiDefaultModelId, mainlandZAiDefaultModelId } from "./zai.js" @@ -135,6 +137,8 @@ export function getProviderDefaultModelId( return opencodeGoDefaultModelId case providerIdentifiers.kenari: return kenariDefaultModelId + case providerIdentifiers.ioIntelligence: + return ioIntelligenceDefaultModelId case providerIdentifiers.nanogpt: return nanoGptDefaultModelId case providerIdentifiers.kimiCode: diff --git a/packages/types/src/providers/io-intelligence.ts b/packages/types/src/providers/io-intelligence.ts new file mode 100644 index 0000000000..5e18806163 --- /dev/null +++ b/packages/types/src/providers/io-intelligence.ts @@ -0,0 +1,13 @@ +import type { ModelInfo } from "../model.js" + +export const IO_INTELLIGENCE_BASE_URL = "https://api.intelligence.io.solutions/api/v1" + +export const ioIntelligenceDefaultModelId = "meta-llama/Llama-3.3-70B-Instruct" + +export const ioIntelligenceDefaultModelInfo: ModelInfo = { + maxTokens: 8192, + contextWindow: 128_000, + supportsImages: false, + supportsPromptCache: false, + description: "IO Intelligence model. The full model catalog is resolved dynamically from the /models endpoint.", +} diff --git a/src/api/__tests__/index.spec.ts b/src/api/__tests__/index.spec.ts index 2fe940f53a..ddf2d60ef6 100644 --- a/src/api/__tests__/index.spec.ts +++ b/src/api/__tests__/index.spec.ts @@ -39,6 +39,7 @@ import { FireworksHandler, FriendliHandler, GeminiHandler, + IOIntelligenceHandler, KenariHandler, KimiCodeHandler, LiteLLMHandler, @@ -100,6 +101,7 @@ const expectedHandlers = { [providerIdentifiers.vercelAiGateway]: VercelAiGatewayHandler, [providerIdentifiers.opencodeGo]: OpencodeGoHandler, [providerIdentifiers.kenari]: KenariHandler, + [providerIdentifiers.ioIntelligence]: IOIntelligenceHandler, [providerIdentifiers.nanogpt]: NanoGptHandler, [providerIdentifiers.zooGateway]: ZooGatewayHandler, [providerIdentifiers.minimax]: MiniMaxHandler, diff --git a/src/api/index.ts b/src/api/index.ts index 98c3c5dc7b..119ad1e189 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -42,6 +42,7 @@ import { VercelAiGatewayHandler, OpencodeGoHandler, KenariHandler, + IOIntelligenceHandler, NanoGptHandler, ZooGatewayHandler, MiniMaxHandler, @@ -225,6 +226,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { return new OpencodeGoHandler(options) case providerIdentifiers.kenari: return new KenariHandler(options) + case providerIdentifiers.ioIntelligence: + return new IOIntelligenceHandler(options) case providerIdentifiers.nanogpt: return new NanoGptHandler(options) case providerIdentifiers.zooGateway: diff --git a/src/api/providers/__tests__/io-intelligence.spec.ts b/src/api/providers/__tests__/io-intelligence.spec.ts new file mode 100644 index 0000000000..11166a1533 --- /dev/null +++ b/src/api/providers/__tests__/io-intelligence.spec.ts @@ -0,0 +1,413 @@ +vi.mock("vscode", () => ({ + workspace: { getConfiguration: () => ({ get: (_key: string, defaultValue?: unknown) => defaultValue }) }, +})) + +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" + +import { ioIntelligenceDefaultModelId, providerIdentifiers } from "@roo-code/types" + +import { buildApiHandler } from "../../index" +import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" +import { IOIntelligenceHandler } from "../io-intelligence" +import { getModels } from "../fetchers/modelCache" + +vi.mock("openai") +vi.mock("../fetchers/modelCache", () => ({ + getModels: vi.fn().mockResolvedValue({ + "meta-llama/Llama-3.3-70B-Instruct": { + 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("IOIntelligenceHandler", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(getModels).mockResolvedValue({ + "meta-llama/Llama-3.3-70B-Instruct": { + maxTokens: 8192, + contextWindow: 128000, + supportsImages: false, + supportsPromptCache: false, + }, + }) + mockCreate.mockResolvedValue(asyncStreamFrom([])) + }) + + it("is constructed by the backend provider registry", () => { + expect(buildApiHandler({ apiProvider: providerIdentifiers.ioIntelligence })).toBeInstanceOf( + IOIntelligenceHandler, + ) + }) + + it("resolves the default model when none is configured", async () => { + const handler = new IOIntelligenceHandler({}) + await collectStream(handler.createMessage("system", messages)) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: ioIntelligenceDefaultModelId }), + 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 IOIntelligenceHandler({ + ioIntelligenceModelId: "deepseek-ai/DeepSeek-V3.2", + }).createMessage("sys", messages), + ) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: "deepseek-ai/DeepSeek-V3.2" }), + 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 IOIntelligenceHandler({ + ioIntelligenceModelId: "meta-llama/Llama-3.3-70B-Instruct", + 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: "meta-llama/Llama-3.3-70B-Instruct", + 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 IOIntelligenceHandler({ ioIntelligenceModelId: "meta-llama/Llama-3.3-70B-Instruct" }).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 IOIntelligenceHandler({ ioIntelligenceModelId: "meta-llama/Llama-3.3-70B-Instruct" }).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 IOIntelligenceHandler({ + ioIntelligenceApiKey: "secret-key", + ioIntelligenceModelId: "meta-llama/Llama-3.3-70B-Instruct", + }) + await expect(collectStream(handler.createMessage("sys", messages))).rejects.toMatchObject({ + message: "IO Intelligence 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( + "[IO Intelligence] 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 IOIntelligenceHandler({ + ioIntelligenceApiKey: "secret-key", + ioIntelligenceModelId: "meta-llama/Llama-3.3-70B-Instruct", + }) + await expect(collectStream(handler.createMessage("sys", messages))).rejects.toMatchObject({ + message: 'IO Intelligence streaming error: {"detail":"invalid key [REDACTED]"}', + }) + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[IO Intelligence] 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 IOIntelligenceHandler({ + ioIntelligenceApiKey: "secret-key", + ioIntelligenceModelId: "meta-llama/Llama-3.3-70B-Instruct", + }) + await expect(collectStream(handler.createMessage("sys", messages))).rejects.toMatchObject({ + message: 'IO Intelligence streaming error: {"detail":"invalid key [REDACTED]"}', + status: 401, + }) + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[IO Intelligence] 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 IOIntelligenceHandler({ + ioIntelligenceApiKey: "secret-key", + ioIntelligenceModelId: "meta-llama/Llama-3.3-70B-Instruct", + }) + await expect(collectStream(handler.createMessage("sys", messages))).rejects.toMatchObject({ + message: "IO Intelligence streaming error: upstream rejected [REDACTED]", + }) + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[IO Intelligence] 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 IOIntelligenceHandler({ ioIntelligenceModelId: "meta-llama/Llama-3.3-70B-Instruct" }) + await expect(handler.completePrompt("ping")).rejects.toMatchObject({ + message: "IO Intelligence 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 IOIntelligenceHandler({ ioIntelligenceModelId: "meta-llama/Llama-3.3-70B-Instruct" }) + expect(await handler.completePrompt("ping")).toBe("ok") + expect(mockCreate).toHaveBeenCalledWith( + { + model: "meta-llama/Llama-3.3-70B-Instruct", + 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 IOIntelligenceHandler({ ioIntelligenceModelId: "meta-llama/Llama-3.3-70B-Instruct" }) + 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 IOIntelligenceHandler({ ioIntelligenceModelId: "meta-llama/Llama-3.3-70B-Instruct" }) + 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 IOIntelligenceHandler({ ioIntelligenceModelId: "meta-llama/Llama-3.3-70B-Instruct" }) + await handler.completePrompt("ping", { abortSignal: signal, timeoutMs: 5_000 }) + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ stream: false }), { signal, timeout: 5_000 }) + }) + + it("does not send the OpenAI Compatible custom headers to the fixed endpoint", () => { + new IOIntelligenceHandler({ + ioIntelligenceApiKey: "test-key", + openAiHeaders: { "X-Custom-Header": "leaked" }, + }) + expect(vi.mocked(OpenAI)).toHaveBeenCalledTimes(1) + const clientOptions = vi.mocked(OpenAI).mock.calls[0][0] + 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 IOIntelligenceHandler({ + ioIntelligenceModelId: "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("completePrompt forwards the configured temperature", async () => { + mockCreate.mockResolvedValue({ + choices: [{ message: { role: "assistant", content: "ok" } }], + }) + const handler = new IOIntelligenceHandler({ + ioIntelligenceModelId: "meta-llama/Llama-3.3-70B-Instruct", + 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__/io-intelligence.spec.ts b/src/api/providers/fetchers/__tests__/io-intelligence.spec.ts new file mode 100644 index 0000000000..e9c8f22a5a --- /dev/null +++ b/src/api/providers/fetchers/__tests__/io-intelligence.spec.ts @@ -0,0 +1,169 @@ +import axios from "axios" + +import { IO_INTELLIGENCE_BASE_URL, ioIntelligenceDefaultModelInfo } from "@roo-code/types" + +import { getIOIntelligenceModels, parseIoIntelligenceModel } from "../io-intelligence" + +vi.mock("axios") + +describe("IO Intelligence model fetcher", () => { + beforeEach(() => vi.clearAllMocks()) + + it("requests the catalog with optional Bearer authorization", async () => { + vi.mocked(axios.get).mockResolvedValue({ data: { data: [] } }) + await getIOIntelligenceModels("key-a") + expect(axios.get).toHaveBeenCalledWith(`${IO_INTELLIGENCE_BASE_URL}/models`, { + headers: { Authorization: "Bearer key-a" }, + timeout: 10_000, + }) + }) + + it("supports unauthenticated catalog requests", async () => { + vi.mocked(axios.get).mockResolvedValue({ data: { data: [] } }) + await getIOIntelligenceModels() + expect(axios.get).toHaveBeenCalledWith(`${IO_INTELLIGENCE_BASE_URL}/models`, { + headers: undefined, + timeout: 10_000, + }) + }) + + it("maps detailed metadata and exact per-million pricing for multiple models", async () => { + vi.mocked(axios.get).mockResolvedValue({ + data: { + unknown_top_level: true, + data: [ + { + id: "vision-model", + name: "Vision Model", + created: 1_789_049_414, + owned_by: "io-intelligence", + max_model_len: null, + context_window: 262_144, + max_tokens: 131_072, + supports_tools: true, + supports_reasoning: true, + supports_prompt_cache: true, + input_modalities: ["text", "image"], + input_token_price: 0.000000306, + output_token_price: 0.000001224, + cache_read_token_price: 0.000000153, + unknown: "allowed", + }, + { id: "text-model", input_modalities: ["text"] }, + ], + }, + }) + + const models = await getIOIntelligenceModels() + expect(Object.keys(models)).toEqual(["vision-model", "text-model"]) + expect(models["vision-model"]).toEqual({ + contextWindow: 262_144, + maxTokens: 131_072, + supportsImages: true, + supportsPromptCache: true, + displayName: "Vision Model", + inputPrice: 0.306, + outputPrice: 1.224, + cacheReadsPrice: 0.153, + }) + expect(models["text-model"].supportsImages).toBe(false) + }) + + it("skips malformed records and models explicitly lacking tool support", async () => { + vi.mocked(axios.get).mockResolvedValue({ + data: { + data: [{ id: "eligible" }, { missing: "id" }, { id: "chat-only", supports_tools: false }], + }, + }) + const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined) + expect(await getIOIntelligenceModels()).toEqual({ + eligible: { + contextWindow: ioIntelligenceDefaultModelInfo.contextWindow, + maxTokens: ioIntelligenceDefaultModelInfo.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__", max_tokens: 1 }, { id: "constructor" }] }, + }) + const models = await getIOIntelligenceModels() + expect(Object.getPrototypeOf(models)).toBeNull() + expect(Object.keys(models)).toEqual(["__proto__", "constructor"]) + expect(models["__proto__"]).toMatchObject({ maxTokens: 1 }) + expect(({} as Record).maxTokens).toBeUndefined() + }) + + it("keeps models with null token metadata and prefers context_window over max_model_len", () => { + expect( + parseIoIntelligenceModel({ id: "both", max_model_len: 65_536, context_window: 262_144 }).contextWindow, + ).toBe(262_144) + + const info = parseIoIntelligenceModel({ + id: "legacy-entry", + max_model_len: 65_536, + context_window: null, + max_tokens: null, + }) + expect(info.contextWindow).toBe(65_536) + expect(info.maxTokens).toBe(ioIntelligenceDefaultModelInfo.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 getIOIntelligenceModels()).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 getIOIntelligenceModels()).toEqual({}) + consoleError.mockRestore() + }, + ) + + it("rejects negative numeric metadata without leaking the API key in errors", async () => { + vi.mocked(axios.get) + .mockResolvedValueOnce({ + data: { + data: [ + { id: "valid-free", input_token_price: 0, output_token_price: 0 }, + { id: "invalid-price", input_token_price: -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 getIOIntelligenceModels("secret-key")).toEqual({ + "valid-free": expect.objectContaining({ inputPrice: 0, outputPrice: 0 }), + }) + expect(await getIOIntelligenceModels("secret-key")).toEqual({}) + expect(consoleError).toHaveBeenLastCalledWith( + "Error fetching IO Intelligence models: upstream rejected [REDACTED]", + ) + + warning.mockRestore() + consoleError.mockRestore() + }) + + it("does not invent absent optional metadata", () => { + const info = parseIoIntelligenceModel({ id: "minimal" }) + expect(info).toEqual({ + contextWindow: ioIntelligenceDefaultModelInfo.contextWindow, + maxTokens: ioIntelligenceDefaultModelInfo.maxTokens, + supportsImages: false, + supportsPromptCache: false, + }) + }) +}) diff --git a/src/api/providers/fetchers/__tests__/modelCache.spec.ts b/src/api/providers/fetchers/__tests__/modelCache.spec.ts index 108aa1827b..4c82d20308 100644 --- a/src/api/providers/fetchers/__tests__/modelCache.spec.ts +++ b/src/api/providers/fetchers/__tests__/modelCache.spec.ts @@ -44,6 +44,7 @@ vi.mock("fs", () => ({ vi.mock("../litellm") vi.mock("../openrouter") vi.mock("../requesty") +vi.mock("../io-intelligence") vi.mock("../kenari") vi.mock("../nanogpt") vi.mock("../moonshot") @@ -70,6 +71,7 @@ import { getModels, getModelsFromCache } from "../modelCache" import { getLiteLLMModels } from "../litellm" import { getOpenRouterModels } from "../openrouter" import { getRequestyModels } from "../requesty" +import { getIOIntelligenceModels } from "../io-intelligence" import { getKenariModels } from "../kenari" import { getNanoGptModels } from "../nanogpt" import { getMoonshotModels } from "../moonshot" @@ -78,12 +80,14 @@ import { getZooGatewayModels } from "../zoo-gateway" const mockGetLiteLLMModels = getLiteLLMModels as Mock const mockGetOpenRouterModels = getOpenRouterModels as Mock const mockGetRequestyModels = getRequestyModels as Mock +const mockGetIOIntelligenceModels = getIOIntelligenceModels as Mock const mockGetKenariModels = getKenariModels as Mock const mockGetNanoGptModels = getNanoGptModels as Mock const mockGetMoonshotModels = getMoonshotModels as Mock const mockGetZooGatewayModels = getZooGatewayModels as Mock const DUMMY_REQUESTY_KEY = "requesty-key-for-testing" +const DUMMY_IOINTELLIGENCE_KEY = "io-intelligence-key-for-testing" describe("getModels with new GetModelsOptions", () => { beforeEach(() => { @@ -216,6 +220,25 @@ describe("getModels with new GetModelsOptions", () => { expect(result).toEqual(mockModels) }) + it("calls getIOIntelligenceModels for the io-intelligence provider", async () => { + const mockModels = { + "meta-llama/Llama-3.3-70B-Instruct": { + maxTokens: 8192, + contextWindow: 128000, + supportsPromptCache: false, + }, + } + mockGetIOIntelligenceModels.mockResolvedValue(mockModels) + + const result = await getModels({ + provider: providerIdentifiers.ioIntelligence, + apiKey: DUMMY_IOINTELLIGENCE_KEY, + }) + + expect(mockGetIOIntelligenceModels).toHaveBeenCalledWith(DUMMY_IOINTELLIGENCE_KEY) + expect(result).toEqual(mockModels) + }) + it("handles errors and re-throws them", async () => { const expectedError = new Error("LiteLLM connection failed") mockGetLiteLLMModels.mockRejectedValue(expectedError) @@ -1123,6 +1146,31 @@ describe("NanoGPT key-scoped cache isolation", () => { }) }) +describe("IO Intelligence key-scoped cache isolation", () => { + const ioIntelligenceModels = { + "meta-llama/Llama-3.3-70B-Instruct": { maxTokens: 8_192, contextWindow: 128_000, supportsPromptCache: false }, + } + + beforeEach(() => { + vi.clearAllMocks() + mockGetIOIntelligenceModels.mockResolvedValue(ioIntelligenceModels) + }) + + 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.ioIntelligence }) + await getModels({ provider: providerIdentifiers.ioIntelligence, apiKey: "ionet-key-a" }) + await getModels({ provider: providerIdentifiers.ioIntelligence, apiKey: "ionet-key-b" }) + + const cacheKeys = mockCache.set.mock.calls.map(([key]) => key as string) + expect(new Set(cacheKeys).size).toBe(3) + expect(cacheKeys).toContain("io-intelligence") + expect(cacheKeys.every((key) => !key.includes("ionet-key-a") && !key.includes("ionet-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/io-intelligence.ts b/src/api/providers/fetchers/io-intelligence.ts new file mode 100644 index 0000000000..2fd61dac3b --- /dev/null +++ b/src/api/providers/fetchers/io-intelligence.ts @@ -0,0 +1,96 @@ +import axios from "axios" +import { z } from "zod" + +import { + IO_INTELLIGENCE_BASE_URL, + ioIntelligenceDefaultModelInfo, + type ModelInfo, + type ModelRecord, +} from "@roo-code/types" + +const ioIntelligenceModelSchema = z.object({ + id: z.string().min(1), + name: z.string().optional(), + max_model_len: z.number().int().positive().nullish(), + context_window: z.number().int().positive().nullish(), + max_tokens: z.number().int().positive().nullish(), + supports_tools: z.boolean().optional(), + supports_prompt_cache: z.boolean().optional(), + input_modalities: z.array(z.string()).optional(), + input_token_price: z.number().nonnegative().optional(), + output_token_price: z.number().nonnegative().optional(), + cache_read_token_price: z.number().nonnegative().optional(), +}) + +export type IOIntelligenceModel = 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 ioIntelligenceModelsResponseSchema = z.object({ + object: z.string().optional(), + data: z.array(z.unknown()), +}) + +export const parseIoIntelligenceModel = (model: IOIntelligenceModel): ModelInfo => ({ + maxTokens: model.max_tokens ?? ioIntelligenceDefaultModelInfo.maxTokens, + contextWindow: model.context_window ?? model.max_model_len ?? ioIntelligenceDefaultModelInfo.contextWindow, + supportsImages: model.input_modalities?.includes("image") ?? false, + supportsPromptCache: model.supports_prompt_cache ?? false, + ...(model.input_token_price !== undefined ? { inputPrice: model.input_token_price * 1_000_000 } : {}), + ...(model.output_token_price !== undefined ? { outputPrice: model.output_token_price * 1_000_000 } : {}), + ...(model.cache_read_token_price !== undefined + ? { cacheReadsPrice: model.cache_read_token_price * 1_000_000 } + : {}), + // The catalog has no free-text description. Its name and id are remote + // strings and ModelInfo.description is rendered as Markdown, so neither is + // copied there; the name is only surfaced as the plain-text displayName. + ...(model.name !== undefined ? { displayName: model.name } : {}), +}) + +/** + * Fetches the public IO Intelligence (io.net) model catalog. + * + * The catalog can be listed without an API key, while a Bearer key scopes the + * visible models for the account (io.net exposes per-tier access). Prices are + * published per token and normalized to per-million-token units. + */ +export async function getIOIntelligenceModels(apiKey?: string): Promise { + try { + const response = await axios.get(`${IO_INTELLIGENCE_BASE_URL}/models`, { + headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined, + timeout: 10_000, + }) + const responseResult = ioIntelligenceModelsResponseSchema.safeParse(response.data) + if (!responseResult.success) { + console.warn("IO Intelligence 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 = ioIntelligenceModelSchema.safeParse(rawModel) + if (!modelResult.success) { + console.warn("Skipping invalid IO Intelligence model entry") + continue + } + + // Zoo Code is agentic-first: io.net marks a few catalog models as not + // supporting tools. An explicit false is authoritative; an omitted flag + // remains unknown and therefore eligible (mirrors the NanoGPT fetcher). + if (modelResult.data.supports_tools === false) { + continue + } + + models[modelResult.data.id] = parseIoIntelligenceModel(modelResult.data) + } + + return models + } catch (error) { + console.error(`Error fetching IO Intelligence models: ${getSafeErrorMessage(error, apiKey)}`) + return {} + } +} diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 50dbe12f6e..fbf3433f56 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -21,6 +21,7 @@ import { getOpenRouterModels } from "./openrouter" import { getVercelAiGatewayModels } from "./vercel-ai-gateway" import { getOpencodeGoModels } from "./opencode-go" import { getKenariModels } from "./kenari" +import { getIOIntelligenceModels } from "./io-intelligence" import { getNanoGptModels } from "./nanogpt" import { getRequestyModels } from "./requesty" import { getUnboundModels } from "./unbound" @@ -97,6 +98,7 @@ const KEY_SCOPED_PROVIDERS: ReadonlySet = new Set([ providerIdentifiers.zooGateway, // Per-session-token account identity providerIdentifiers.kimiCode, // Per-session-token account identity providerIdentifiers.nanogpt, // Public catalog can still vary by API-key allowlist + providerIdentifiers.ioIntelligence, // Public catalog can still vary by API-key allowlist ]) // Providers whose model lists are scoped to the signed-in user (e.g. per-account @@ -255,6 +257,10 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise { provider: providerIdentifiers.nanogpt, options: { provider: providerIdentifiers.nanogpt }, }, + { + provider: providerIdentifiers.ioIntelligence, + options: { provider: providerIdentifiers.ioIntelligence }, + }, ] // Refresh each provider in background (fire and forget) diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index 8ba1eae382..3d2cab72c6 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -30,6 +30,7 @@ export { FriendliHandler } from "./friendli" export { VercelAiGatewayHandler } from "./vercel-ai-gateway" export { OpencodeGoHandler } from "./opencode-go" export { KenariHandler } from "./kenari" +export { IOIntelligenceHandler } from "./io-intelligence" export { NanoGptHandler } from "./nanogpt" export { ZooGatewayHandler } from "./zoo-gateway" export { MiniMaxHandler } from "./minimax" diff --git a/src/api/providers/io-intelligence.ts b/src/api/providers/io-intelligence.ts new file mode 100644 index 0000000000..7fbe287209 --- /dev/null +++ b/src/api/providers/io-intelligence.ts @@ -0,0 +1,202 @@ +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" + +import { + IO_INTELLIGENCE_BASE_URL, + ioIntelligenceDefaultModelId, + ioIntelligenceDefaultModelInfo, + providerIdentifiers, +} from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../shared/api" + +import type { ApiStream } from "../transform/stream" +import { convertToOpenAiMessages } from "../transform/openai-format" +import type { ApiHandlerCreateMessageMetadata, CompletePromptOptions, SingleCompletionHandler } from "../index" +import { RouterProvider } from "./router-provider" +import { handleProviderError } from "./utils/error-handler" +import { extractReasoningFromDelta } from "./utils/extract-reasoning" + +/** + * IO Intelligence (io.net) provider. + * + * OpenAI-compatible Chat Completions endpoint: + * https://api.intelligence.io.solutions/api/v1/chat/completions + * + * Model ids are Hugging Face-style `org/name` identifiers and are resolved + * dynamically from the public /models endpoint. Supports text generation, + * reasoning content (DeepSeek/GLM style), tool calls, and non-streaming + * prompt completion. + */ +export class IOIntelligenceHandler 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.ioIntelligence, + baseURL: IO_INTELLIGENCE_BASE_URL, + apiKey: options.ioIntelligenceApiKey, + modelId: options.ioIntelligenceModelId, + defaultModelId: ioIntelligenceDefaultModelId, + defaultModelInfo: ioIntelligenceDefaultModelInfo, + }) + } + + private createSafeError(operation: string, error: unknown): Error { + const apiKey = this.options.ioIntelligenceApiKey + 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, "IO Intelligence", { + messagePrefix: operation, + // The transformer input can also come from `error.error.metadata.raw`. + messageTransformer: (message) => `IO Intelligence ${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 (DeepSeek R1, GLM) stream reasoning via + // reasoning_content with an OpenRouter-style `reasoning` 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 IO Intelligence 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 + } + if (options?.timeoutMs !== undefined) { + completionOptions.timeout = options.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 2319a6b1a5..b4d3b1b8aa 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -309,6 +309,25 @@ describe("ContextProxy", () => { }) describe("setProviderSettings", () => { + it("stores and returns the complete IO Intelligence configuration across secret and global state", async () => { + await proxy.setProviderSettings({ + apiProvider: providerIdentifiers.ioIntelligence, + ioIntelligenceApiKey: "ionet-secret", + ioIntelligenceModelId: "meta-llama/Llama-3.3-70B-Instruct", + }) + + expect(mockSecrets.store).toHaveBeenCalledWith("ioIntelligenceApiKey", "ionet-secret") + expect(mockGlobalState.update).toHaveBeenCalledWith( + "ioIntelligenceModelId", + "meta-llama/Llama-3.3-70B-Instruct", + ) + expect(proxy.getProviderSettings()).toMatchObject({ + apiProvider: providerIdentifiers.ioIntelligence, + ioIntelligenceApiKey: "ionet-secret", + ioIntelligenceModelId: "meta-llama/Llama-3.3-70B-Instruct", + }) + }) + 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 97c4dd877e..d176e066a1 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -3801,6 +3801,7 @@ describe("ClineProvider - Router Models", () => { moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + "io-intelligence": mockModels, nanogpt: mockModels, "kimi-code": {}, }, @@ -3836,6 +3837,7 @@ describe("ClineProvider - Router Models", () => { .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm fail .mockResolvedValueOnce(mockModels) // opencode-go (public endpoint) .mockResolvedValueOnce(mockModels) // kenari (public endpoint) + .mockResolvedValueOnce(mockModels) // io-intelligence (public endpoint) .mockResolvedValueOnce(mockModels) // nanogpt (public endpoint) await messageHandler({ type: "requestRouterModels" }) @@ -3857,6 +3859,7 @@ describe("ClineProvider - Router Models", () => { moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + "io-intelligence": mockModels, nanogpt: mockModels, "kimi-code": {}, }, @@ -3958,6 +3961,7 @@ describe("ClineProvider - Router Models", () => { moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + "io-intelligence": mockModels, nanogpt: mockModels, "kimi-code": {}, }, diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index 5a4b3e7be3..d5c7d96eb2 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -94,6 +94,8 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { return { "requesty/model": { contextWindow: 8192, supportsPromptCache: false } } case providerIdentifiers.vercelAiGateway: return { "vercel/model": { contextWindow: 8192, supportsPromptCache: false } } + case providerIdentifiers.ioIntelligence: + return { "io/model": { contextWindow: 8192, supportsPromptCache: false } } case providerIdentifiers.litellm: return { "litellm/model": { contextWindow: 8192, supportsPromptCache: false } } default: @@ -162,6 +164,8 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { return { "requesty/model": { contextWindow: 8192, supportsPromptCache: false } } case providerIdentifiers.vercelAiGateway: return { "vercel/model": { contextWindow: 8192, supportsPromptCache: false } } + case providerIdentifiers.ioIntelligence: + return { "io/model": { contextWindow: 8192, supportsPromptCache: false } } case providerIdentifiers.litellm: return { "litellm/model": { contextWindow: 8192, supportsPromptCache: false } } default: @@ -210,6 +214,8 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { return { "requesty/model": { contextWindow: 8192, supportsPromptCache: false } } case providerIdentifiers.vercelAiGateway: return { "vercel/model": { contextWindow: 8192, supportsPromptCache: false } } + case providerIdentifiers.ioIntelligence: + return { "io/model": { contextWindow: 8192, supportsPromptCache: false } } case providerIdentifiers.litellm: return { "litellm/model": { contextWindow: 8192, supportsPromptCache: false } } default: @@ -447,6 +453,103 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { expect(getModelsMock).toHaveBeenCalledWith(deepSeekOptions) }) + // One catalog per IO Intelligence key, so a fetch made with the wrong key publishes the wrong catalog. + const ioIntelligenceCatalogs: Record< + string, + Record + > = { + "stored-io-key": { "io/stored-key-model": { contextWindow: 8192, supportsPromptCache: false } }, + "unsaved-io-key": { "io/unsaved-key-model": { contextWindow: 8192, supportsPromptCache: false } }, + "": { "io/public-model": { contextWindow: 8192, supportsPromptCache: false } }, + } + + const mockIoIntelligenceCatalogsByKey = () => { + const baseGetModels = getModelsMock.getMockImplementation() + getModelsMock.mockImplementation(async (options) => + options?.provider === providerIdentifiers.ioIntelligence + ? ioIntelligenceCatalogs[options.apiKey] + : baseGetModels?.(options), + ) + } + + const expectPublishedIoIntelligenceCatalog = (apiKey: string) => { + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ + type: RouterModelsMessageType.routerModels, + routerModels: expect.objectContaining({ + [providerIdentifiers.ioIntelligence]: ioIntelligenceCatalogs[apiKey], + }), + }), + ) + } + + it("fetches IO Intelligence models with the stored key and does not flush the cache", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { ioIntelligenceApiKey: "stored-io-key" }, + }) + mockIoIntelligenceCatalogsByKey() + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + }) + + const ioFlushCalls = flushModelsMock.mock.calls.filter( + (c) => c[0]?.provider === providerIdentifiers.ioIntelligence, + ) + expect(ioFlushCalls.length).toBe(0) + + const ioCalls = getModelsMock.mock.calls.filter((c) => c[0]?.provider === providerIdentifiers.ioIntelligence) + expect(ioCalls.length).toBe(1) + expect(ioCalls[0][0]).toEqual({ provider: providerIdentifiers.ioIntelligence, apiKey: "stored-io-key" }) + expectPublishedIoIntelligenceCatalog("stored-io-key") + }) + + it("flushes IO Intelligence models with an unsaved key from message values instead of the stored key", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { ioIntelligenceApiKey: "stored-io-key" }, + }) + mockIoIntelligenceCatalogsByKey() + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { ioIntelligenceApiKey: "unsaved-io-key" }, + }) + + const ioFlushCalls = flushModelsMock.mock.calls.filter( + (c) => c[0]?.provider === providerIdentifiers.ioIntelligence, + ) + expect(ioFlushCalls.length).toBe(1) + expect(ioFlushCalls[0][0]).toEqual({ provider: providerIdentifiers.ioIntelligence, apiKey: "unsaved-io-key" }) + + const ioCalls = getModelsMock.mock.calls.filter((c) => c[0]?.provider === providerIdentifiers.ioIntelligence) + expect(ioCalls.length).toBe(1) + expect(ioCalls[0][0]).toEqual({ provider: providerIdentifiers.ioIntelligence, apiKey: "unsaved-io-key" }) + expectPublishedIoIntelligenceCatalog("unsaved-io-key") + }) + + it("treats an explicitly empty unsaved IO Intelligence key as the public catalog, not the stored key", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { ioIntelligenceApiKey: "stored-io-key" }, + }) + mockIoIntelligenceCatalogsByKey() + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { ioIntelligenceApiKey: "" }, + }) + + const ioFlushCalls = flushModelsMock.mock.calls.filter( + (c) => c[0]?.provider === providerIdentifiers.ioIntelligence, + ) + expect(ioFlushCalls.length).toBe(1) + expect(ioFlushCalls[0][0]).toEqual({ provider: providerIdentifiers.ioIntelligence, apiKey: "" }) + + const ioCalls = getModelsMock.mock.calls.filter((c) => c[0]?.provider === providerIdentifiers.ioIntelligence) + expect(ioCalls.length).toBe(1) + expect(ioCalls[0][0]).toEqual({ provider: providerIdentifiers.ioIntelligence, apiKey: "" }) + expectPublishedIoIntelligenceCatalog("") + }) + it("fetches Moonshot models when stored Moonshot credentials exist", async () => { mockProvider.getState.mockResolvedValue({ apiConfiguration: { @@ -467,6 +570,8 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { return { "requesty/model": { contextWindow: 8192, supportsPromptCache: false } } case providerIdentifiers.vercelAiGateway: return { "vercel/model": { contextWindow: 8192, supportsPromptCache: false } } + case providerIdentifiers.ioIntelligence: + return { "io/model": { contextWindow: 8192, supportsPromptCache: false } } case providerIdentifiers.litellm: return { "litellm/model": { contextWindow: 8192, supportsPromptCache: false } } default: @@ -554,6 +659,8 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { return { "requesty/model": { contextWindow: 8192, supportsPromptCache: false } } case providerIdentifiers.vercelAiGateway: return { "vercel/model": { contextWindow: 8192, supportsPromptCache: false } } + case providerIdentifiers.ioIntelligence: + return { "io/model": { contextWindow: 8192, supportsPromptCache: false } } case providerIdentifiers.litellm: return { "litellm/model": { contextWindow: 8192, supportsPromptCache: false } } default: @@ -605,6 +712,8 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { return { "requesty/model": { contextWindow: 8192, supportsPromptCache: false } } case providerIdentifiers.vercelAiGateway: return { "vercel/model": { contextWindow: 8192, supportsPromptCache: false } } + case providerIdentifiers.ioIntelligence: + return { "io/model": { contextWindow: 8192, supportsPromptCache: false } } case providerIdentifiers.litellm: return { "litellm/model": { contextWindow: 8192, supportsPromptCache: false } } default: diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 4c2a301965..f05db6e463 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -556,6 +556,8 @@ describe("webviewMessageHandler - requestRouterModels", () => { ) // Kenari's /models endpoint is public, so it is fetched like the other no-auth routers. expect(mockGetModels).toHaveBeenCalledWith(expect.objectContaining({ provider: providerIdentifiers.kenari })) + // IO Intelligence's /models catalog is public and may optionally be scoped by a key. + expect(mockGetModels).toHaveBeenCalledWith({ provider: providerIdentifiers.ioIntelligence, apiKey: undefined }) // NanoGPT's detailed catalog is public and may optionally be scoped by a key. expect(mockGetModels).toHaveBeenCalledWith({ provider: providerIdentifiers.nanogpt, apiKey: undefined }) @@ -576,6 +578,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + "io-intelligence": mockModels, nanogpt: mockModels, "kimi-code": {}, }, @@ -824,6 +827,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + "io-intelligence": mockModels, nanogpt: mockModels, "kimi-code": {}, }, @@ -851,6 +855,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm .mockResolvedValueOnce(mockModels) // opencode-go .mockResolvedValueOnce(mockModels) // kenari + .mockResolvedValueOnce(mockModels) // io-intelligence .mockResolvedValueOnce(mockModels) // nanogpt await webviewMessageHandler(mockClineProvider, { @@ -889,6 +894,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { moonshot: {}, "opencode-go": mockModels, kenari: mockModels, + "io-intelligence": mockModels, nanogpt: mockModels, "kimi-code": {}, }, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 34a35ea3ca..5a0c60474c 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1132,6 +1132,7 @@ export const webviewMessageHandler = async ( [providerIdentifiers.moonshot]: {}, [providerIdentifiers.opencodeGo]: {}, [providerIdentifiers.kenari]: {}, + [providerIdentifiers.ioIntelligence]: {}, [providerIdentifiers.nanogpt]: {}, [providerIdentifiers.kimiCode]: {}, } @@ -1302,6 +1303,18 @@ export const webviewMessageHandler = async ( options: { provider: providerIdentifiers.kenari, apiKey: kenariApiKey }, }) + // IO Intelligence's /models catalog is public; an optional key can scope + // the visible model set. Prefer an explicitly supplied unsaved key. + const ioIntelligenceApiKey = message?.values?.ioIntelligenceApiKey ?? apiConfiguration.ioIntelligenceApiKey + if (message?.values?.ioIntelligenceApiKey !== undefined) { + await flushModels({ provider: providerIdentifiers.ioIntelligence, apiKey: ioIntelligenceApiKey }, true) + } + + candidates.push({ + key: providerIdentifiers.ioIntelligence, + options: { provider: providerIdentifiers.ioIntelligence, apiKey: ioIntelligenceApiKey }, + }) + // 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/ProfileValidator.ts b/src/shared/ProfileValidator.ts index 923582bd06..60e5c02b7c 100644 --- a/src/shared/ProfileValidator.ts +++ b/src/shared/ProfileValidator.ts @@ -78,6 +78,8 @@ export class ProfileValidator { return profile.ollamaModelId case providerIdentifiers.requesty: return profile.requestyModelId + case providerIdentifiers.ioIntelligence: + return profile.ioIntelligenceModelId case providerIdentifiers.unbound: return profile.unboundModelId case providerIdentifiers.fakeAi: diff --git a/src/shared/__tests__/ProfileValidator.spec.ts b/src/shared/__tests__/ProfileValidator.spec.ts index 865fa5cf51..ddf282f13f 100644 --- a/src/shared/__tests__/ProfileValidator.spec.ts +++ b/src/shared/__tests__/ProfileValidator.spec.ts @@ -26,6 +26,7 @@ describe("ProfileValidator", () => { ["ollama", { ollamaModelId: "model" }], ["requesty", { requestyModelId: "model" }], ["unbound", { unboundModelId: "model" }], + ["ioIntelligence", { ioIntelligenceModelId: "model" }], ])("resolves %s model fields through canonical identifiers", (identifierKey, profileSettings) => { const canonicalIdentifier = providerIdentifiers[identifierKey as keyof typeof providerIdentifiers] const modelId = "model" @@ -281,6 +282,25 @@ describe("ProfileValidator", () => { expect(ProfileValidator.isProfileAllowed(profile, allowList)).toBe(true) }) + // Test for io-intelligence provider which uses ioIntelligenceModelId + it(`should extract ioIntelligenceModelId for io-intelligence provider`, () => { + const allowList: OrganizationAllowList = { + allowAll: false, + providers: { + [providerIdentifiers.ioIntelligence]: { allowAll: false, models: ["test-model"] }, + }, + } + const profile: ProviderSettings = { + apiProvider: providerIdentifiers.ioIntelligence, + ioIntelligenceModelId: "test-model", + } + + expect(ProfileValidator.isProfileAllowed(profile, allowList)).toBe(true) + expect( + ProfileValidator.isProfileAllowed({ ...profile, ioIntelligenceModelId: "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 d1cc21cad0..abfe370ab1 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -190,6 +190,7 @@ const dynamicProviderExtras = { [providerIdentifiers.moonshot]: {} as { apiKey?: string; baseUrl?: string }, [providerIdentifiers.opencodeGo]: {} as { apiKey?: string }, [providerIdentifiers.kenari]: {} as { apiKey?: string }, + [providerIdentifiers.ioIntelligence]: {} as { apiKey?: string }, [providerIdentifiers.nanogpt]: {} as { apiKey?: string }, [providerIdentifiers.kimiCode]: {} as { apiKey?: string }, } as const satisfies Record diff --git a/webview-ui/playwright/gallery/stories.tsx b/webview-ui/playwright/gallery/stories.tsx index 04ca36b2c6..d5321a3c48 100644 --- a/webview-ui/playwright/gallery/stories.tsx +++ b/webview-ui/playwright/gallery/stories.tsx @@ -73,6 +73,39 @@ export const stories: Record = { ) }, + "io-intelligence-settings": async () => { + const [{ AppProviders }, { IOIntelligence }, { providerIdentifiers }] = await Promise.all([ + import("../AppProviders"), + import("@/components/settings/providers/IOIntelligence"), + import("@roo-code/types"), + ]) + type ProviderSettings = React.ComponentProps["apiConfiguration"] + + function IOIntelligenceSettingsStory() { + const [apiConfiguration, setApiConfiguration] = useState({ + apiProvider: providerIdentifiers.ioIntelligence, + }) + return ( +
+
+ + setApiConfiguration((current) => ({ ...current, [field]: value })) + } + organizationAllowList={{ allowAll: true, providers: {} }} + /> +
+
+ ) + } + + return ( + + + + ) + }, "layout-clipped-text": () => (
diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 714b507774..01f4c7d8c1 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -82,6 +82,7 @@ import { VercelAiGateway, OpenCodeGo, Kenari, + IOIntelligence, NanoGPT, ZooGateway, MiniMax, @@ -692,6 +693,17 @@ const ApiOptions = ({ /> )} + {selectedProvider === providerIdentifiers.ioIntelligence && ( + + )} + {selectedProvider === providerIdentifiers.nanogpt && ( { VercelAiGateway: provider("provider-vercel-ai-gateway"), OpenCodeGo: provider("provider-opencode-go"), Kenari: provider("provider-kenari"), + IOIntelligence: provider("provider-io-intelligence"), NanoGPT: provider("provider-nanogpt"), ZooGateway: provider("provider-zoo-gateway"), MiniMax: provider("provider-minimax"), @@ -358,6 +359,7 @@ describe("ApiOptions interactions", () => { providerIdentifiers.vercelAiGateway, providerIdentifiers.opencodeGo, providerIdentifiers.kenari, + providerIdentifiers.ioIntelligence, 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 58e23511a7..9706533982 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 @@ -215,6 +215,7 @@ describe("ApiOptions Provider Filtering", () => { expect(providerValues).toContain("ollama") expect(providerValues).toContain("lmstudio") expect(providerValues).toContain("litellm") + expect(providerValues).toContain("io-intelligence") 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 341fffe856..073d6c0a2a 100644 --- a/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx @@ -430,6 +430,7 @@ describe("ApiOptions", () => { expect(optionTexts).toContain("OpenAI") expect(optionTexts).toContain("Anthropic") expect(optionTexts).toContain("NanoGPT") + expect(optionTexts).toContain("IO Intelligence") // Note: The mock doesn't implement search functionality, so we're just verifying // that the select element is rendered with the expected options diff --git a/webview-ui/src/components/settings/__tests__/ModelPicker.spec.tsx b/webview-ui/src/components/settings/__tests__/ModelPicker.spec.tsx index 9a21ff678f..de08eca01f 100644 --- a/webview-ui/src/components/settings/__tests__/ModelPicker.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ModelPicker.spec.tsx @@ -6,6 +6,7 @@ import { QueryClient } from "@tanstack/react-query" import { type Mock } from "vitest" import { + ioIntelligenceDefaultModelId, litellmDefaultModelId, type ModelInfo, type ProviderSettings, @@ -364,4 +365,84 @@ describe("ModelPicker", () => { expect(screen.getByTestId("model-picker-button")).not.toHaveTextContent(litellmDefaultModelId) }) }) + + describe("IO Intelligence custom model selection", () => { + const ioIntelligenceModels: Record = { + "meta-llama/Llama-3.3-70B-Instruct": { description: "Catalog model", ...modelInfo }, + } + + const renderIOIntelligencePicker = (apiConfiguration: ProviderSettings, setField: SetApiConfigurationField) => + renderWithExtensionState( + , + { queryClient }, + ) + + beforeEach(() => { + mockUseRouterModels.mockReturnValue(createRouterModelsResult({ "io-intelligence": ioIntelligenceModels })) + }) + + it("keeps a custom model ID in the picker instead of reverting to the default", async () => { + // The stored id is what the handler sends requests with, so the picker + // must keep displaying it even though the fetched catalog lacks it. + const customModelId = "custom-org/custom-model" + let apiConfiguration: ProviderSettings = { apiProvider: providerIdentifiers.ioIntelligence } + const setField = vi.fn(function (field: K, value: ProviderSettings[K]) { + apiConfiguration = { ...apiConfiguration, [field]: value } + }) + + const { rerender } = await act(async () => { + return renderIOIntelligencePicker(apiConfiguration, setField) + }) + + expect(screen.getByTestId("model-picker-button")).toHaveTextContent(ioIntelligenceDefaultModelId) + + await act(async () => { + fireEvent.click(screen.getByTestId("model-picker-button")) + }) + await act(async () => { + vi.advanceTimersByTime(100) + }) + await act(async () => { + fireEvent.input(screen.getByTestId("model-input"), { target: { value: customModelId } }) + }) + await act(async () => { + vi.advanceTimersByTime(100) + }) + await act(async () => { + fireEvent.click(screen.getByTestId("use-custom-model")) + }) + await act(async () => { + vi.advanceTimersByTime(100) + }) + + expect(setField).toHaveBeenCalledWith("ioIntelligenceModelId", customModelId) + + await act(async () => { + rerender( + , + ) + }) + + expect(screen.getByTestId("model-picker-button")).toHaveTextContent(customModelId) + expect(screen.getByTestId("model-picker-button")).not.toHaveTextContent(ioIntelligenceDefaultModelId) + }) + }) }) diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx index 72e1cc7c5d..f95d37300f 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx @@ -761,4 +761,85 @@ describe("SettingsView - Unsaved Changes Detection", () => { expect(onDone).toHaveBeenCalledOnce() expect(postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "upsertApiConfiguration" })) }) + + it("buffers and saves the complete IO Intelligence provider configuration from cached state", async () => { + const liveApiConfiguration = { + apiProvider: providerIdentifiers.ioIntelligence, + ioIntelligenceApiKey: "original-key", + ioIntelligenceModelId: "meta-llama/original", + } + ;(useExtensionState as ReturnType).mockReturnValue({ + ...defaultExtensionState, + apiConfiguration: liveApiConfiguration, + }) + vi.mocked(ApiOptions).mockImplementation(({ apiConfiguration, setApiConfigurationField }) => ( +
+ setApiConfigurationField("ioIntelligenceApiKey", event.target.value)} + /> + setApiConfigurationField("ioIntelligenceModelId", event.target.value)} + /> +
+ )) + + renderWithExtensionState(, { queryClient }) + + expect(await screen.findByTestId("cached-ionet-key")).toHaveValue("original-key") + expect(screen.getByTestId("cached-ionet-model")).toHaveValue("meta-llama/original") + + fireEvent.change(screen.getByTestId("cached-ionet-key"), { target: { value: "unsaved-key" } }) + fireEvent.change(screen.getByTestId("cached-ionet-model"), { target: { value: "zai-org/next" } }) + + expect(liveApiConfiguration).toEqual({ + apiProvider: providerIdentifiers.ioIntelligence, + ioIntelligenceApiKey: "original-key", + ioIntelligenceModelId: "meta-llama/original", + }) + expect(postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "upsertApiConfiguration" })) + + fireEvent.click(screen.getByTestId("save-button")) + + expect(postMessage).toHaveBeenCalledWith({ + type: "upsertApiConfiguration", + text: "default", + apiConfiguration: { + apiProvider: providerIdentifiers.ioIntelligence, + ioIntelligenceApiKey: "unsaved-key", + ioIntelligenceModelId: "zai-org/next", + }, + }) + }) + + it("discards IO Intelligence cached edits and restores the extension values", async () => { + const onDone = vi.fn() + ;(useExtensionState as ReturnType).mockReturnValue({ + ...defaultExtensionState, + apiConfiguration: { + apiProvider: providerIdentifiers.ioIntelligence, + ioIntelligenceApiKey: "saved-key", + ioIntelligenceModelId: "meta-llama/saved", + }, + }) + vi.mocked(ApiOptions).mockImplementation(({ apiConfiguration, setApiConfigurationField }) => ( + setApiConfigurationField("ioIntelligenceApiKey", event.target.value)} + /> + )) + + renderWithExtensionState(, { queryClient }) + fireEvent.change(await screen.findByTestId("cached-ionet-key"), { target: { value: "discard-me" } }) + fireEvent.click(screen.getByText("settings:common.done")) + fireEvent.click(await screen.findByText("settings:unsavedChangesDialog.discardButton")) + + await waitFor(() => expect(screen.getByTestId("cached-ionet-key")).toHaveValue("saved-key")) + expect(onDone).toHaveBeenCalledOnce() + expect(postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "upsertApiConfiguration" })) + }) }) diff --git a/webview-ui/src/components/settings/constants.ts b/webview-ui/src/components/settings/constants.ts index 8c51aca2fe..f252c879b3 100644 --- a/webview-ui/src/components/settings/constants.ts +++ b/webview-ui/src/components/settings/constants.ts @@ -70,6 +70,7 @@ export const PROVIDERS: Array<{ value: string; label: string; proxy: boolean }> { value: providerIdentifiers.vercelAiGateway, label: "Vercel AI Gateway", proxy: false }, { value: providerIdentifiers.opencodeGo, label: "Opencode Go", proxy: false }, { value: providerIdentifiers.kenari, label: "Kenari", proxy: false }, + { value: providerIdentifiers.ioIntelligence, label: "IO Intelligence", 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/IOIntelligence.tsx b/webview-ui/src/components/settings/providers/IOIntelligence.tsx new file mode 100644 index 0000000000..07a3a1d8bc --- /dev/null +++ b/webview-ui/src/components/settings/providers/IOIntelligence.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, + ioIntelligenceDefaultModelId, + 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 IOIntelligenceProps = { + apiConfiguration: ProviderSettings + setApiConfigurationField: (field: K, value: ProviderSettings[K]) => void + routerModels?: RouterModels + organizationAllowList: OrganizationAllowList + modelValidationError?: string + simplifySettings?: boolean +} + +export const IOIntelligence = ({ + apiConfiguration, + setApiConfigurationField, + routerModels, + organizationAllowList, + modelValidationError, + simplifySettings, +}: IOIntelligenceProps) => { + 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.ioIntelligence, + ioIntelligenceApiKey: apiConfiguration.ioIntelligenceApiKey, + }, + }) + }, + 250, + [apiConfiguration.ioIntelligenceApiKey], + ) + + return ( + <> + + + +
+ {t("settings:providers.apiKeyStorageNotice")} +
+ {!apiConfiguration.ioIntelligenceApiKey && ( + + {t("settings:providers.ioIntelligence.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 new file mode 100644 index 0000000000..ae6f7bc63a --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/IOIntelligence.spec.tsx @@ -0,0 +1,181 @@ +import { act, fireEvent, render, screen } from "@testing-library/react" + +import { + type OrganizationAllowList, + type ProviderSettings, + type RouterModels, + ioIntelligenceDefaultModelId, + providerIdentifiers, + RouterModelsMessageType, +} from "@roo-code/types" + +import { IOIntelligence } from "../IOIntelligence" + +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("IOIntelligence", () => { + 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": { + "meta-llama/Llama-3.3-70B-Instruct": { 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({ ioIntelligenceApiKey: "stored" }) + + expect(screen.getByTestId("ionet-api-key")).toHaveAttribute("type", "password") + expect(screen.getByText("settings:providers.ioIntelligence.apiKey")).toBeInTheDocument() + expect(screen.queryByTestId("ionet-get-key")).not.toBeInTheDocument() + expect(screen.getByTestId("model-picker")).toHaveAttribute( + "data-default-model-id", + ioIntelligenceDefaultModelId, + ) + expect(screen.getByTestId("model-picker")).toHaveAttribute("data-model-id-key", "ioIntelligenceModelId") + 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("ionet-get-key")).toHaveAttribute("href", "https://ai.io.net/ai/api-keys") + expect(screen.getByText("settings:providers.ioIntelligence.getApiKey")).toBeInTheDocument() + }) + + it("updates the cached key with exact values", () => { + renderComponent({ ioIntelligenceApiKey: "" }) + + fireEvent.input(screen.getByTestId("ionet-api-key"), { target: { value: "new-secret" } }) + + expect(setApiConfigurationField).toHaveBeenCalledWith("ioIntelligenceApiKey", "new-secret") + }) + + it("refreshes models with the unsaved cached key 250ms after it stops changing", () => { + vi.useFakeTimers() + const rerenderWithKey = (rerender: ReturnType["rerender"], ioIntelligenceApiKey: string) => + rerender( + , + ) + + const { rerender } = renderComponent({ ioIntelligenceApiKey: "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.ioIntelligence, ioIntelligenceApiKey: "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.ioIntelligence, ioIntelligenceApiKey: "unsaved-key" }, + }) + }) + + it("drops a pending model refresh when it unmounts", () => { + vi.useFakeTimers() + const { unmount } = renderComponent({ ioIntelligenceApiKey: "first-key" }) + + unmount() + act(() => vi.advanceTimersByTime(250)) + + expect(postMessageMock).not.toHaveBeenCalled() + }) +}) diff --git a/webview-ui/src/components/settings/providers/__tests__/IOIntelligence.visual.tsx b/webview-ui/src/components/settings/providers/__tests__/IOIntelligence.visual.tsx new file mode 100644 index 0000000000..f845abe39e --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/IOIntelligence.visual.tsx @@ -0,0 +1,10 @@ +import { expect, test } from "../../../../../playwright/coverage-fixture" +import { mountedStory } from "../../../../../playwright/mounted-story" + +test("renders the IO Intelligence settings for an unconfigured profile", async ({ mount, page }) => { + await page.setViewportSize({ width: 480, height: 480 }) + const component = mountedStory(await mount("io-intelligence-settings")) + const story = component.getByTestId("io-intelligence-settings-story") + + await expect(story).toHaveScreenshot("io-intelligence-settings.png") +}) 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 bb810caa75..698fa90e29 100644 --- a/webview-ui/src/components/settings/providers/__tests__/NanoGPT.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/NanoGPT.spec.tsx @@ -103,6 +103,7 @@ describe("NanoGPT", () => { "opencode-go": {}, kenari: {}, nanogpt: { "openai/test": { contextWindow: 1, maxTokens: 1, supportsPromptCache: false } }, + "io-intelligence": {}, "kimi-code": {}, ollama: {}, lmstudio: {}, diff --git a/webview-ui/src/components/settings/providers/__tests__/__screenshots__/io-intelligence-settings.png b/webview-ui/src/components/settings/providers/__tests__/__screenshots__/io-intelligence-settings.png new file mode 100644 index 0000000000..b5ad92f3ef Binary files /dev/null and b/webview-ui/src/components/settings/providers/__tests__/__screenshots__/io-intelligence-settings.png differ diff --git a/webview-ui/src/components/settings/providers/index.ts b/webview-ui/src/components/settings/providers/index.ts index 12ea4cd786..ee583e563e 100644 --- a/webview-ui/src/components/settings/providers/index.ts +++ b/webview-ui/src/components/settings/providers/index.ts @@ -26,6 +26,7 @@ export { Friendli } from "./Friendli" export { VercelAiGateway } from "./VercelAiGateway" export { OpenCodeGo } from "./OpenCodeGo" export { Kenari } from "./Kenari" +export { IOIntelligence } from "./IOIntelligence" 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 b695b3529e..a8203fa740 100644 --- a/webview-ui/src/components/settings/utils/__tests__/providerModelConfig.spec.ts +++ b/webview-ui/src/components/settings/utils/__tests__/providerModelConfig.spec.ts @@ -1,5 +1,6 @@ import { anthropicDefaultModelId, + ioIntelligenceDefaultModelId, mainlandZAiDefaultModelId, nanoGptDefaultModelId, providerIdentifiers, @@ -181,6 +182,13 @@ describe("providerModelConfig", () => { default: nanoGptDefaultModelId, }) }) + + it("returns IO Intelligence's dynamic model field and fallback", () => { + expect(getProviderModelConfig(providerIdentifiers.ioIntelligence)).toEqual({ + field: "ioIntelligenceModelId", + default: ioIntelligenceDefaultModelId, + }) + }) }) describe("getProviderDocsSlug", () => { diff --git a/webview-ui/src/components/settings/utils/providerModelConfig.ts b/webview-ui/src/components/settings/utils/providerModelConfig.ts index 7230e4cbe4..5ae0798dd9 100644 --- a/webview-ui/src/components/settings/utils/providerModelConfig.ts +++ b/webview-ui/src/components/settings/utils/providerModelConfig.ts @@ -29,6 +29,7 @@ import { vercelAiGatewayDefaultModelId, opencodeGoDefaultModelId, kenariDefaultModelId, + ioIntelligenceDefaultModelId, nanoGptDefaultModelId, zooGatewayDefaultModelId, zaiApiLineConfigs, @@ -145,6 +146,10 @@ const PROVIDER_MODEL_CONFIG: Partial> }, [providerIdentifiers.opencodeGo]: { field: "opencodeGoModelId", default: opencodeGoDefaultModelId }, [providerIdentifiers.kenari]: { field: "kenariModelId", default: kenariDefaultModelId }, + [providerIdentifiers.ioIntelligence]: { + field: "ioIntelligenceModelId", + default: ioIntelligenceDefaultModelId, + }, [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 d5ef35672b..0580296b44 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -13,6 +13,8 @@ import { anthropicModels, BEDROCK_1M_CONTEXT_MODEL_IDS, litellmDefaultModelInfo, + ioIntelligenceDefaultModelId, + ioIntelligenceDefaultModelInfo, kenariDefaultModelId, kenariDefaultModelInfo, nanoGptDefaultModelId, @@ -1127,6 +1129,102 @@ describe("useSelectedModel", () => { }) }) + describe("io-intelligence provider", () => { + it("uses dynamic metadata for the configured IO Intelligence model", () => { + const dynamicInfo: ModelInfo = { + maxTokens: 8_192, + contextWindow: 131_072, + supportsPromptCache: true, + description: "Dynamic IO Intelligence model", + } + mockUseRouterModels.mockReturnValue( + createRouterModelsResult({ "io-intelligence": { "zai-org/GLM-4.6": dynamicInfo } }), + ) + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + + const { result } = renderHook( + () => + useSelectedModel({ + apiProvider: providerIdentifiers.ioIntelligence, + ioIntelligenceModelId: "zai-org/GLM-4.6", + }), + { wrapper: createWrapper() }, + ) + + expect(result.current.id).toBe("zai-org/GLM-4.6") + expect(result.current.info).toEqual(dynamicInfo) + }) + + it("uses IO Intelligence's shared fallback when its dynamic catalog is empty", () => { + mockUseRouterModels.mockReturnValue(createRouterModelsResult({ "io-intelligence": {} })) + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + + const { result } = renderHook( + () => + useSelectedModel({ + apiProvider: providerIdentifiers.ioIntelligence, + // ioIntelligenceModelId intentionally omitted + }), + { wrapper: createWrapper() }, + ) + + expect(result.current.id).toBe(ioIntelligenceDefaultModelId) + expect(result.current.info).toEqual(ioIntelligenceDefaultModelInfo) + }) + + it("preserves a configured model ID that is absent from the fetched catalog", () => { + mockUseRouterModels.mockReturnValue( + createRouterModelsResult({ + "io-intelligence": { + "meta-llama/Llama-3.3-70B-Instruct": { + maxTokens: 8_192, + contextWindow: 131_072, + supportsPromptCache: false, + }, + }, + }), + ) + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + + const { result } = renderHook( + () => + useSelectedModel({ + apiProvider: providerIdentifiers.ioIntelligence, + ioIntelligenceModelId: "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(ioIntelligenceDefaultModelInfo) + }) + + 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.ioIntelligence, + ioIntelligenceModelId: "zai-org/GLM-4.6", + }), + { wrapper: createWrapper() }, + ) + + expect(result.current.id).toBe("zai-org/GLM-4.6") + expect(result.current.info).toEqual(ioIntelligenceDefaultModelInfo) + 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 8d4b70ad4a..6a96aa2d69 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -31,6 +31,7 @@ import { lMStudioDefaultModelInfo, opencodeGoDefaultModelInfo, kenariDefaultModelInfo, + ioIntelligenceDefaultModelInfo, nanoGptDefaultModelInfo, BEDROCK_1M_CONTEXT_MODEL_IDS, VERTEX_1M_CONTEXT_MODEL_IDS, @@ -95,14 +96,14 @@ export const useSelectedModel = (apiConfiguration?: ProviderSettings) => { const needLmStudio = typeof lmStudioModelId !== "undefined" const needOllama = typeof ollamaModelId !== "undefined" - // LiteLLM 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 it only needs the fetch to settle. Other - // dynamic providers require a populated provider entry before the - // selection is resolved. + // LiteLLM and IO Intelligence 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.litellm || dynamicProvider === providerIdentifiers.ioIntelligence ? !routerModels.isLoading : routerModels.data && routerModels.data[dynamicProvider] !== undefined && @@ -451,6 +452,15 @@ function getSelectedModel({ const info = routerModels[providerIdentifiers.kenari]?.[id] ?? kenariDefaultModelInfo return { id, info } } + case providerIdentifiers.ioIntelligence: { + // 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.ioIntelligenceModelId || defaultModelId + const info = routerModels[providerIdentifiers.ioIntelligence]?.[id] ?? ioIntelligenceDefaultModelInfo + 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 762a1290af..2903d2e1a1 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -402,6 +402,12 @@ "getOpencodeGoApiKey": "Obtenir clau API de Opencode Go", "kenariApiKey": "Clau API de Kenari", "getKenariApiKey": "Obtenir clau API de Kenari", + "ioIntelligence": { + "provider": "IO Intelligence", + "apiKey": "Clau API de IO Intelligence", + "getApiKey": "Obtenir clau API de IO Intelligence", + "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 f56984bc73..7b21c8a086 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -402,6 +402,12 @@ "getOpencodeGoApiKey": "Opencode Go API-Schlüssel erhalten", "kenariApiKey": "Kenari API-Schlüssel", "getKenariApiKey": "Kenari API-Schlüssel erhalten", + "ioIntelligence": { + "provider": "IO Intelligence", + "apiKey": "IO Intelligence API-Schlüssel", + "getApiKey": "IO Intelligence 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 77f8a4f86d..13f950c5dd 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -482,6 +482,12 @@ "getOpencodeGoApiKey": "Get Opencode Go API Key", "kenariApiKey": "Kenari API Key", "getKenariApiKey": "Get Kenari API Key", + "ioIntelligence": { + "provider": "IO Intelligence", + "apiKey": "IO Intelligence API Key", + "getApiKey": "Get IO Intelligence 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 e730483fa0..feb303811d 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -402,6 +402,12 @@ "getOpencodeGoApiKey": "Obtener clave API de Opencode Go", "kenariApiKey": "Clave API de Kenari", "getKenariApiKey": "Obtener clave API de Kenari", + "ioIntelligence": { + "provider": "IO Intelligence", + "apiKey": "Clave API de IO Intelligence", + "getApiKey": "Obtener clave API de IO Intelligence", + "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 78770da21d..44a583ca2c 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -402,6 +402,12 @@ "getOpencodeGoApiKey": "Obtenir la clé API Opencode Go", "kenariApiKey": "Clé API Kenari", "getKenariApiKey": "Obtenir la clé API Kenari", + "ioIntelligence": { + "provider": "IO Intelligence", + "apiKey": "Clé API IO Intelligence", + "getApiKey": "Obtenir une clé API IO Intelligence", + "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 413d3515bc..6807eb5cd8 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -402,6 +402,12 @@ "getOpencodeGoApiKey": "Opencode Go API कुंजी प्राप्त करें", "kenariApiKey": "Kenari API कुंजी", "getKenariApiKey": "Kenari API कुंजी प्राप्त करें", + "ioIntelligence": { + "provider": "IO Intelligence", + "apiKey": "IO Intelligence API कुंजी", + "getApiKey": "IO Intelligence 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 9b9928da64..f18e0fb4fe 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -402,6 +402,12 @@ "getOpencodeGoApiKey": "Dapatkan Opencode Go API Key", "kenariApiKey": "Kenari API Key", "getKenariApiKey": "Dapatkan Kenari API Key", + "ioIntelligence": { + "provider": "IO Intelligence", + "apiKey": "Kunci API IO Intelligence", + "getApiKey": "Dapatkan Kunci API IO Intelligence", + "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 8a43ec3d35..b2e8c0c070 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -402,6 +402,12 @@ "getOpencodeGoApiKey": "Ottieni chiave API Opencode Go", "kenariApiKey": "Chiave API Kenari", "getKenariApiKey": "Ottieni chiave API Kenari", + "ioIntelligence": { + "provider": "IO Intelligence", + "apiKey": "Chiave API IO Intelligence", + "getApiKey": "Ottieni chiave API IO Intelligence", + "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 b2cfbe977e..2bb2719e08 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -402,6 +402,12 @@ "getOpencodeGoApiKey": "Opencode Go APIキーを取得", "kenariApiKey": "Kenari APIキー", "getKenariApiKey": "Kenari APIキーを取得", + "ioIntelligence": { + "provider": "IO Intelligence", + "apiKey": "IO Intelligence APIキー", + "getApiKey": "IO Intelligence 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 1ff9addeb4..5ed72d1abe 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -402,6 +402,12 @@ "getOpencodeGoApiKey": "Opencode Go API 키 받기", "kenariApiKey": "Kenari API 키", "getKenariApiKey": "Kenari API 키 받기", + "ioIntelligence": { + "provider": "IO Intelligence", + "apiKey": "IO Intelligence API 키", + "getApiKey": "IO Intelligence 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 4361d091a1..72eae0ba33 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -402,6 +402,12 @@ "getOpencodeGoApiKey": "Opencode Go API-sleutel ophalen", "kenariApiKey": "Kenari API-sleutel", "getKenariApiKey": "Kenari API-sleutel ophalen", + "ioIntelligence": { + "provider": "IO Intelligence", + "apiKey": "IO Intelligence API-sleutel", + "getApiKey": "IO Intelligence 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 277bcaa470..242fe9cb58 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -402,6 +402,12 @@ "getOpencodeGoApiKey": "Uzyskaj klucz API Opencode Go", "kenariApiKey": "Klucz API Kenari", "getKenariApiKey": "Uzyskaj klucz API Kenari", + "ioIntelligence": { + "provider": "IO Intelligence", + "apiKey": "Klucz API IO Intelligence", + "getApiKey": "Uzyskaj klucz API IO Intelligence", + "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 8ce67bcd48..cdd290a339 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -402,6 +402,12 @@ "getOpencodeGoApiKey": "Obter chave API do Opencode Go", "kenariApiKey": "Chave API do Kenari", "getKenariApiKey": "Obter chave API do Kenari", + "ioIntelligence": { + "provider": "IO Intelligence", + "apiKey": "Chave de API do IO Intelligence", + "getApiKey": "Obter chave de API do IO Intelligence", + "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 0ac516c190..4b57881db1 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -402,6 +402,12 @@ "getOpencodeGoApiKey": "Получить ключ API Opencode Go", "kenariApiKey": "Ключ API Kenari", "getKenariApiKey": "Получить ключ API Kenari", + "ioIntelligence": { + "provider": "IO Intelligence", + "apiKey": "API-ключ IO Intelligence", + "getApiKey": "Получить API-ключ IO Intelligence", + "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 5d3a5cb89a..c8b4ff6c83 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -402,6 +402,12 @@ "getOpencodeGoApiKey": "Opencode Go API Anahtarı Al", "kenariApiKey": "Kenari API Anahtarı", "getKenariApiKey": "Kenari API Anahtarı Al", + "ioIntelligence": { + "provider": "IO Intelligence", + "apiKey": "IO Intelligence API Anahtarı", + "getApiKey": "IO Intelligence 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 adb1be64e3..dcaafcf105 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -402,6 +402,12 @@ "getOpencodeGoApiKey": "Lấy khóa API Opencode Go", "kenariApiKey": "Khóa API Kenari", "getKenariApiKey": "Lấy khóa API Kenari", + "ioIntelligence": { + "provider": "IO Intelligence", + "apiKey": "Khóa API IO Intelligence", + "getApiKey": "Lấy khóa API IO Intelligence", + "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 916f629efe..f9c345f231 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -402,6 +402,12 @@ "getOpencodeGoApiKey": "获取 Opencode Go API 密钥", "kenariApiKey": "Kenari API 密钥", "getKenariApiKey": "获取 Kenari API 密钥", + "ioIntelligence": { + "provider": "IO Intelligence", + "apiKey": "IO Intelligence API 密钥", + "getApiKey": "获取 IO Intelligence 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 d1f9258dcf..1ce1a19fd6 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -429,6 +429,12 @@ "getOpencodeGoApiKey": "取得 Opencode Go API 金鑰", "kenariApiKey": "Kenari API 金鑰", "getKenariApiKey": "取得 Kenari API 金鑰", + "ioIntelligence": { + "provider": "IO Intelligence", + "apiKey": "IO Intelligence API 金鑰", + "getApiKey": "取得 IO Intelligence 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 a1936b89d7..05f067ce91 100644 --- a/webview-ui/src/utils/__tests__/validate.spec.ts +++ b/webview-ui/src/utils/__tests__/validate.spec.ts @@ -52,6 +52,7 @@ describe("Model Validation Functions", () => { requesty: {}, unbound: {}, litellm: {}, + "io-intelligence": {}, ollama: {}, lmstudio: {}, "vercel-ai-gateway": {}, @@ -416,6 +417,27 @@ describe("Model Validation Functions", () => { }) }) + describe("IO Intelligence validation", () => { + it("returns an apiKey error when the IO Intelligence API key is unset", () => { + const config: ProviderSettings = { + apiProvider: providerIdentifiers.ioIntelligence, + ioIntelligenceModelId: "zai-org/GLM-4.6", + } + + expect(validateApiConfiguration(config, mockRouterModels)).toBe("settings:validation.apiKey") + }) + + it("accepts an IO Intelligence API key and selected model", () => { + const config: ProviderSettings = { + apiProvider: providerIdentifiers.ioIntelligence, + ioIntelligenceApiKey: "valid-key", + ioIntelligenceModelId: "zai-org/GLM-4.6", + } + + 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 3e32116374..ea3e6d5e7e 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -182,6 +182,11 @@ function validateModelsAndKeysProvided( return i18next.t("settings:validation.apiKey") } break + case providerIdentifiers.ioIntelligence: + if (!apiConfiguration.ioIntelligenceApiKey) { + return i18next.t("settings:validation.apiKey") + } + break case providerIdentifiers.nanogpt: if (!apiConfiguration.nanoGptApiKey) { return i18next.t("settings:validation.apiKey")