Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7e3ddbd
Add Novita AI provider
Alex-yang00 Jun 23, 2026
dbb482a
Address Novita provider PR feedback
Alex-yang00 Jun 23, 2026
becbd04
Address Novita provider review feedback
Alex-yang00 Jul 2, 2026
e55203e
Merge remote-tracking branch 'origin/main' into pr-697
Alex-yang00 Jul 2, 2026
b757163
Fix Novita provider CI checks
Alex-yang00 Jul 2, 2026
87540b7
Fix Novita e2e mock completion fixture
Alex-yang00 Jul 2, 2026
8819ca1
Handle AI SDK tool-call args
Alex-yang00 Jul 2, 2026
d728ba2
Capture Novita completion ask text
Alex-yang00 Jul 2, 2026
f60a4d8
Handle AI SDK tool input events
Alex-yang00 Jul 2, 2026
a0115a9
Fix AI SDK tool input event typing
Alex-yang00 Jul 2, 2026
4ccfd8d
Handle AI SDK tool call arguments
Alex-yang00 Jul 2, 2026
87c4e79
Use recorded Novita e2e fixture
Alex-yang00 Jul 2, 2026
9cc44cb
Deduplicate AI SDK streamed tool calls
Alex-yang00 Jul 2, 2026
0485180
Restore Novita tool-result fixture predicate
Alex-yang00 Jul 2, 2026
ecf753f
Stabilize Novita e2e mock fixture
Alex-yang00 Jul 2, 2026
3374b66
Stabilize Novita e2e fixture matching
Alex-yang00 Jul 2, 2026
c506816
Emit complete AI SDK tool calls
Alex-yang00 Jul 2, 2026
cf3fa16
Match Novita e2e tool result by id
Alex-yang00 Jul 2, 2026
a05a3b4
Broaden Novita e2e result fixture match
Alex-yang00 Jul 2, 2026
1688f61
Constrain initial Novita e2e fixture
Alex-yang00 Jul 2, 2026
85bfd41
Match Novita e2e follow-up requests
Alex-yang00 Jul 2, 2026
f35012e
Ignore streaming AI SDK tool availability
Alex-yang00 Jul 2, 2026
0251d3a
Relax Novita e2e completion assertion
Alex-yang00 Jul 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ for this exact support, so if you are having problems or if you have question, j
- [简体中文](locales/zh-CN/README.md)
- [繁體中文](locales/zh-TW/README.md)
- ...
</details>
</details>

---

Expand Down
10 changes: 9 additions & 1 deletion apps/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ By default, the CLI auto-approves actions and runs in interactive TUI mode:
export OPENROUTER_API_KEY=sk-or-v1-...

roo "What is this project?" -w ~/Documents/my-project

# Or use Novita AI explicitly:
export NOVITA_API_KEY=...
roo "What is this project?" --provider novita -w ~/Documents/my-project
Comment thread
Alex-yang00 marked this conversation as resolved.
```

You can also run without a prompt and enter it interactively in TUI mode:
Expand Down Expand Up @@ -160,7 +164,7 @@ If you never used Roo Code Router, you can ignore this section entirely.
| `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` |
| `-a, --require-approval` | Require manual approval before actions execute | `false` |
| `-k, --api-key <key>` | API key for the LLM provider | From env var |
| `--provider <provider>` | API provider (anthropic, openai-native, gemini, openrouter, vercel-ai-gateway) | `openrouter` |
| `--provider <provider>` | API provider (anthropic, openai-native, gemini, openrouter, novita, vercel-ai-gateway) | `openrouter` |
| `-m, --model <model>` | Model to use | `anthropic/claude-opus-4.6` |
| `--mode <mode>` | Mode to start in (code, architect, ask, debug, etc.) | `code` |
| `--terminal-shell <path>` | Absolute shell path for inline terminal command execution | Auto-detected shell |
Expand All @@ -186,6 +190,7 @@ The CLI will look for API keys in environment variables if not provided via `--a
| anthropic | `ANTHROPIC_API_KEY` |
| openai-native | `OPENAI_API_KEY` |
| openrouter | `OPENROUTER_API_KEY` |
| novita | `NOVITA_API_KEY` |
| gemini | `GOOGLE_API_KEY` |
| vercel-ai-gateway | `VERCEL_AI_GATEWAY_API_KEY` |

Expand Down Expand Up @@ -234,6 +239,9 @@ The CLI will look for API keys in environment variables if not provided via `--a
# Run directly from source (no build required)
pnpm dev --provider openrouter --api-key $OPENROUTER_API_KEY --print "Hello"

# Novita AI (OpenAI-compatible)
pnpm dev --provider novita --api-key $NOVITA_API_KEY --model moonshotai/kimi-k2.7-code --print "Hello"

# Run tests
pnpm test

Expand Down
17 changes: 16 additions & 1 deletion apps/cli/src/lib/utils/__tests__/provider.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getApiKeyFromEnv } from "../provider.js"
import { getApiKeyFromEnv, getProviderSettings } from "../provider.js"

describe("getApiKeyFromEnv", () => {
const originalEnv = process.env
Expand All @@ -22,6 +22,11 @@ describe("getApiKeyFromEnv", () => {
expect(getApiKeyFromEnv("openrouter")).toBe("test-openrouter-key")
})

it("should return API key from environment variable for novita", () => {
process.env.NOVITA_API_KEY = "test-novita-key"
expect(getApiKeyFromEnv("novita")).toBe("test-novita-key")
})

it("should return API key from environment variable for openai", () => {
process.env.OPENAI_API_KEY = "test-openai-key"
expect(getApiKeyFromEnv("openai-native")).toBe("test-openai-key")
Expand All @@ -32,3 +37,13 @@ describe("getApiKeyFromEnv", () => {
expect(getApiKeyFromEnv("anthropic")).toBeUndefined()
})
})

describe("getProviderSettings", () => {
it("should map Novita key and model into provider settings", () => {
expect(getProviderSettings("novita", "test-novita-key", "moonshotai/kimi-k2.7-code")).toEqual({
apiProvider: "novita",
novitaApiKey: "test-novita-key",
apiModelId: "moonshotai/kimi-k2.7-code",
})
})
})
5 changes: 5 additions & 0 deletions apps/cli/src/lib/utils/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const envVarMap: Record<SupportedProvider, string> = {
"openai-native": "OPENAI_API_KEY",
gemini: "GOOGLE_API_KEY",
openrouter: "OPENROUTER_API_KEY",
novita: "NOVITA_API_KEY",
"vercel-ai-gateway": "VERCEL_AI_GATEWAY_API_KEY",
}

Expand Down Expand Up @@ -43,6 +44,10 @@ export function getProviderSettings(
if (apiKey) config.openRouterApiKey = apiKey
if (model) config.openRouterModelId = model
break
case "novita":
if (apiKey) config.novitaApiKey = apiKey
if (model) config.apiModelId = model
break
case "vercel-ai-gateway":
if (apiKey) config.vercelAiGatewayApiKey = apiKey
if (model) config.vercelAiGatewayModelId = model
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const supportedProviders = [
"openai-native",
"gemini",
"openrouter",
"novita",
"vercel-ai-gateway",
] as const satisfies ProviderName[]

Expand Down
19 changes: 19 additions & 0 deletions apps/vscode-e2e/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,25 @@ After converting the generated `openai-*.json` files into stable named fixtures,
USE_MOCK=true TEST_FILE=deepseek-v4.test pnpm --filter @roo-code/vscode-e2e test:run
```

### Novita AI (`suite/providers/novita.test.ts`)

Novita exposes an OpenAI-compatible endpoint, so the suite redirects the provider through aimock with
`novitaBaseUrl: ${AIMOCK_URL}/v1`. The default model is `moonshotai/kimi-k2.7-code`; override it with
`NOVITA_MODEL_ID` only when refreshing matching fixtures.

Record Novita fixtures with the targeted file filter so aimock proxies OpenAI-compatible traffic to
`https://api.novita.ai/openai`:

```sh
NOVITA_API_KEY=<key> TEST_FILE=novita.test pnpm --filter @roo-code/vscode-e2e test:record
```

After converting generated `openai-*.json` files into `fixtures/novita.json`, verify in mock mode:

```sh
USE_MOCK=true TEST_FILE=novita.test pnpm --filter @roo-code/vscode-e2e test:run
```

## Tests that use a non-default provider

If your test calls `api.setConfiguration({ apiProvider: "anthropic", ... })`, point aimock at the
Expand Down
10 changes: 9 additions & 1 deletion apps/vscode-e2e/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { config } from "@roo-code/config-eslint/base"

/** @type {import("eslint").Linter.Config} */
export default [...config]
export default [
...config,
{
files: ["**/*.d.ts"],
rules: {
"no-var": "off",
},
},
]
20 changes: 20 additions & 0 deletions apps/vscode-e2e/fixtures/novita.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"fixtures": [
{
"match": {
"model": "moonshotai/kimi-k2.7-code",
"userMessage": "[ERROR] You did not use a tool in your previous response!",
"sequenceIndex": 0
},
"response": {
"toolCalls": [
{
"name": "attempt_completion",
"arguments": "{\"result\":\"NOVITA_E2E_MARKER\"}",
"id": "call_novita_retry_done"
}
]
}
}
]
}
54 changes: 54 additions & 0 deletions apps/vscode-e2e/src/fixtures/novita.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { LLMock, type ChatCompletionRequest } from "@copilotkit/aimock"

function requestIncludes(req: ChatCompletionRequest, text: string) {
const messages = Array.isArray(req?.messages) ? req.messages : []

return JSON.stringify(messages).includes(text)
}

function isInitialNovitaToolProbe(req: ChatCompletionRequest) {
return (
requestIncludes(req, "novita-e2e:tool-use") &&
!requestIncludes(req, "call_novita_read") &&
!requestIncludes(req, "NOVITA_E2E_MARKER") &&
!requestIncludes(req, "[ERROR] You did not use a tool in your previous response!")
)
}

function hasNovitaReadFileResult(req: ChatCompletionRequest) {
return requestIncludes(req, "novita-e2e:tool-use") && !isInitialNovitaToolProbe(req)
}

export function addNovitaFixtures(mock: InstanceType<typeof LLMock>) {
mock.addFixture({
match: {
model: "moonshotai/kimi-k2.7-code",
predicate: hasNovitaReadFileResult,
},
response: {
toolCalls: [
{
name: "attempt_completion",
arguments: JSON.stringify({ result: "NOVITA_E2E_MARKER" }),
id: "call_novita_done",
},
],
},
})

mock.addFixture({
match: {
model: "moonshotai/kimi-k2.7-code",
predicate: isInitialNovitaToolProbe,
},
response: {
toolCalls: [
{
name: "read_file",
arguments: JSON.stringify({ path: "novita-e2e-marker.txt" }),
id: "call_novita_read",
},
],
},
})
}
31 changes: 26 additions & 5 deletions apps/vscode-e2e/src/runTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { addSearchFilesResultFixtures } from "./fixtures/search-files"
import { addSubtaskFixtures } from "./fixtures/subtasks"
import { addUseMcpToolResultFixtures } from "./fixtures/use-mcp-tool"
import { addWriteToFileResultFixtures } from "./fixtures/write-to-file"
import { addNovitaFixtures } from "./fixtures/novita"

function getCliFlagValue(flag: string) {
return process.argv.find((arg, index) => process.argv[index - 1] === flag)
Expand All @@ -29,6 +30,14 @@ function isDeepSeekTargetedRun(testFile?: string, testGrep?: string) {
return testGrep?.toLowerCase().includes("deepseek") ?? false
}

function isNovitaTargetedRun(testFile?: string, testGrep?: string) {
if (testFile?.toLowerCase().includes("novita.test")) {
return true
}

return testGrep?.toLowerCase().includes("novita") ?? false
}

function isBedrockTargetedRun(testFile?: string, testGrep?: string) {
if (testFile?.toLowerCase().includes("bedrock.test")) {
return true
Expand All @@ -42,28 +51,35 @@ async function main() {
const testGrep = getCliFlagValue("--grep") || process.env.TEST_GREP
const testFile = getCliFlagValue("--file") || process.env.TEST_FILE
const isDeepSeekTest = isDeepSeekTargetedRun(testFile, testGrep)
const isNovitaTest = isNovitaTargetedRun(testFile, testGrep)
const isGeminiTest = testFile?.toLowerCase().includes("gemini.test") ?? false
const isBedrockTest = isBedrockTargetedRun(testFile, testGrep)

if (isRecord && isDeepSeekTest && !process.env.DEEPSEEK_API_KEY) {
throw new Error("AIMOCK_RECORD=true requires DEEPSEEK_API_KEY to record DeepSeek fixtures")
}

if (isRecord && isNovitaTest && !process.env.NOVITA_API_KEY) {
throw new Error("AIMOCK_RECORD=true requires NOVITA_API_KEY to record Novita fixtures")
}

if (isRecord && isGeminiTest && !process.env.GEMINI_API_KEY && !process.env.GOOGLE_API_KEY) {
throw new Error("AIMOCK_RECORD=true requires GEMINI_API_KEY to record Gemini fixtures")
}

if (isRecord && !isDeepSeekTest && !isGeminiTest && !process.env.OPENROUTER_API_KEY) {
if (isRecord && !isDeepSeekTest && !isNovitaTest && !isGeminiTest && !process.env.OPENROUTER_API_KEY) {
throw new Error("AIMOCK_RECORD=true requires OPENROUTER_API_KEY to record fixtures")
}

// Record mode always needs aimock running (to capture traffic).
// Replay mode starts aimock when no real API key is present or USE_MOCK is forced.
const hasRealApiKey = isDeepSeekTest
? !!process.env.DEEPSEEK_API_KEY
: isBedrockTest
? true // Bedrock test starts its own binary-event-stream mock server when no real token
: !!(process.env.OPENROUTER_API_KEY || process.env.ANTHROPIC_API_KEY)
: isNovitaTest
? !!process.env.NOVITA_API_KEY
: isBedrockTest
? true // Bedrock test starts its own binary-event-stream mock server when no real token
: !!(process.env.OPENROUTER_API_KEY || process.env.ANTHROPIC_API_KEY)
const useMock = isRecord || !hasRealApiKey || process.env.USE_MOCK === "true"

let mock: InstanceType<typeof LLMock> | undefined
Expand Down Expand Up @@ -94,7 +110,11 @@ async function main() {
// Use /api (not /api/v1) — aimock appends the request path (/v1/chat/completions)
// so including /v1 here would produce a doubled /v1/v1 upstream URL.
providers: {
openai: isDeepSeekTest ? "https://api.deepseek.com" : "https://openrouter.ai/api",
openai: isDeepSeekTest
? "https://api.deepseek.com"
: isNovitaTest
? "https://api.novita.ai/openai"
: "https://openrouter.ai/api",
// aimock forwards the x-api-key header from the Anthropic SDK to the real API.
anthropic: "https://api.anthropic.com",
// aimock forwards the x-goog-api-key header from the Google AI SDK.
Expand All @@ -117,6 +137,7 @@ async function main() {
addSubtaskFixtures(mock)
addUseMcpToolResultFixtures(mock)
addWriteToFileResultFixtures(mock)
addNovitaFixtures(mock)

// The modes test (switch_mode → ask) triggers a second API call whose last
// user message starts with <environment_details> directly — no <user_message>
Expand Down
Loading
Loading