Skip to content
This repository was archived by the owner on Feb 25, 2026. It is now read-only.

Commit a8337fd

Browse files
committed
feat: add roll-call command for batch-testing model connectivity
Add a new 'roll-call' subcommand that allows users to batch-test multiple models for connectivity and latency. This helps users discover which provider/model pairs are available and working. Features: - Filter models by regex pattern (required for safety) - Configurable prompt, timeout, and parallelization - JSON or table output formats - Proper provider options handling for reasoning models Fixes applied: - Apply ProviderTransform options (maxOutputTokens, temperature, etc.) - Enable reasoning for dedicated thinking models via Kilo Gateway - Prevent accidental full model list testing without explicit filter Closes #457
1 parent d900b2f commit a8337fd

3 files changed

Lines changed: 265 additions & 0 deletions

File tree

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import type { Argv } from "yargs"
2+
import { Instance } from "../../project/instance"
3+
import { Provider } from "../../provider/provider"
4+
import { ProviderTransform } from "../../provider/transform"
5+
import { cmd } from "./cmd"
6+
import { UI } from "../ui"
7+
import { APICallError } from "ai"
8+
import { ProviderError } from "../../provider/error"
9+
import { generateText } from "ai"
10+
import { randomUUID } from "crypto"
11+
12+
export const RollCallCommand = cmd({
13+
command: "roll-call <filter>",
14+
describe: "batch-test models matching a filter for connectivity and latency",
15+
builder: (yargs: Argv) => {
16+
return yargs
17+
.positional("filter", {
18+
type: "string",
19+
describe: "regex to filter models by provider/modelID (required)",
20+
demandOption: true,
21+
})
22+
.option("prompt", {
23+
type: "string",
24+
default: "Hello",
25+
describe: "Prompt to send to each model",
26+
})
27+
.option("timeout", {
28+
type: "number",
29+
default: 25000,
30+
describe: "Timeout for each model call in milliseconds",
31+
})
32+
.option("parallel", {
33+
type: "number",
34+
default: 5,
35+
describe: "Number of parallel model calls",
36+
})
37+
.option("retries", {
38+
type: "number",
39+
default: 0,
40+
describe: "Number of additional retries for each model call",
41+
})
42+
.option("verbose", {
43+
type: "boolean",
44+
default: false,
45+
describe: "Show verbose output",
46+
})
47+
.option("quiet", {
48+
type: "boolean",
49+
default: false,
50+
describe: "Suppress non-error output",
51+
})
52+
.option("output", {
53+
type: "string",
54+
choices: ["table", "json"],
55+
default: "table",
56+
describe: "Output format",
57+
})
58+
},
59+
handler: async (args) => {
60+
await rollCallHandler(args)
61+
},
62+
})
63+
64+
interface RollCallResult {
65+
model: string
66+
access: boolean
67+
snippet: string
68+
latency: number | null
69+
errorType: string | null
70+
errorMessage: string | null
71+
}
72+
73+
export async function rollCallHandler(args: any) {
74+
const { prompt, timeout, filter, parallel, output, verbose, quiet } = args
75+
76+
if (!quiet) {
77+
UI.println(`${UI.Style.TEXT_INFO}Starting roll call for models with prompt: "${prompt}"${UI.Style.TEXT_NORMAL}`)
78+
UI.println(
79+
`${UI.Style.TEXT_INFO}Timeout per model: ${timeout}ms, Parallel calls: ${parallel}${UI.Style.TEXT_NORMAL}`,
80+
)
81+
}
82+
83+
await Instance.provide({
84+
directory: process.cwd(),
85+
async fn() {
86+
const providers = await Provider.list()
87+
const modelsToTest: { providerID: string; modelID: string; model: Provider.Model }[] = []
88+
89+
for (const [providerID, provider] of Object.entries(providers)) {
90+
for (const [modelID, model] of Object.entries(provider.models)) {
91+
const fullName = `${providerID}/${modelID}`
92+
if (filter) {
93+
try {
94+
const regex = new RegExp(filter, "i")
95+
if (!regex.test(fullName)) continue
96+
} catch (e) {
97+
UI.error(`Invalid filter regex: ${filter}`)
98+
return
99+
}
100+
}
101+
modelsToTest.push({ providerID, modelID, model })
102+
}
103+
}
104+
105+
if (modelsToTest.length === 0) {
106+
if (!quiet) UI.println(`${UI.Style.TEXT_WARNING}No models to test after filtering.${UI.Style.TEXT_NORMAL}`)
107+
return
108+
}
109+
110+
if (!quiet) {
111+
UI.println(`${UI.Style.TEXT_INFO}Prompting ${modelsToTest.length} models...${UI.Style.TEXT_NORMAL}`)
112+
}
113+
114+
const results: RollCallResult[] = []
115+
const queue = [...modelsToTest]
116+
const activePromises: Promise<void>[] = []
117+
118+
const processModel = async (item: (typeof modelsToTest)[0]) => {
119+
const { providerID, modelID, model } = item
120+
const fullName = `${providerID}/${modelID}`
121+
const startTime = Date.now()
122+
let access = false
123+
let snippet = ""
124+
let latency: number | null = null
125+
let errorType: string | null = null
126+
let errorMessage: string | null = null
127+
128+
try {
129+
const languageModel = await Provider.getLanguage(model)
130+
131+
// Build provider options similar to how session/index.ts does it
132+
const sessionID = randomUUID()
133+
const baseOptions = ProviderTransform.options({ model, sessionID })
134+
const providerOptions = ProviderTransform.providerOptions(model, baseOptions)
135+
const maxTokens = ProviderTransform.maxOutputTokens(model)
136+
const temperature = ProviderTransform.temperature(model)
137+
const topP = ProviderTransform.topP(model)
138+
const topK = ProviderTransform.topK(model)
139+
140+
const { text } = await generateText({
141+
model: languageModel,
142+
prompt,
143+
abortSignal: AbortSignal.timeout(timeout),
144+
maxOutputTokens: maxTokens,
145+
temperature,
146+
topP,
147+
topK,
148+
providerOptions,
149+
})
150+
access = true
151+
snippet = text.substring(0, 50).replace(/\n/g, " ")
152+
latency = Date.now() - startTime
153+
} catch (e: any) {
154+
latency = Date.now() - startTime
155+
if (e instanceof APICallError) {
156+
const parsedError = ProviderError.parseAPICallError({
157+
providerID,
158+
error: e,
159+
})
160+
errorType = parsedError.type
161+
errorMessage = parsedError.message
162+
} else {
163+
errorType = "unknown"
164+
errorMessage = e.message || "An unknown error occurred"
165+
}
166+
}
167+
168+
results.push({
169+
model: fullName,
170+
access,
171+
snippet,
172+
latency,
173+
errorType,
174+
errorMessage,
175+
})
176+
177+
if (verbose && !quiet) {
178+
if (access) {
179+
UI.println(`${UI.Style.TEXT_SUCCESS}${UI.Style.TEXT_NORMAL} ${fullName} - ${latency}ms`)
180+
} else {
181+
UI.println(`${UI.Style.TEXT_DANGER}${UI.Style.TEXT_NORMAL} ${fullName} - ${errorType}: ${errorMessage}`)
182+
}
183+
}
184+
}
185+
186+
while (queue.length > 0 || activePromises.length > 0) {
187+
while (queue.length > 0 && activePromises.length < parallel) {
188+
const item = queue.shift()!
189+
const promise = processModel(item).finally(() => {
190+
const index = activePromises.indexOf(promise)
191+
if (index > -1) {
192+
activePromises.splice(index, 1)
193+
}
194+
})
195+
activePromises.push(promise)
196+
}
197+
if (activePromises.length > 0) {
198+
await Promise.race(activePromises)
199+
}
200+
}
201+
202+
if (quiet) return
203+
204+
if (output === "json") {
205+
console.log(JSON.stringify(results, null, 2))
206+
} else {
207+
const headers = ["Model", "Access", "Snippet", "Latency"]
208+
209+
const truncate = (text: string, maxLen: number) => {
210+
if (maxLen < 10) return text.substring(0, maxLen - 3) + "..."
211+
return text.length > maxLen ? text.substring(0, maxLen - 3) + "..." : text
212+
}
213+
214+
const rows = results.map((r) => [
215+
r.model,
216+
r.access ? "YES" : "NO",
217+
r.access ? r.snippet : r.errorMessage ? `(${r.errorMessage})` : "",
218+
r.latency !== null ? `${r.latency}ms` : "N/A",
219+
])
220+
221+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)))
222+
223+
const totalWidth = widths.reduce((a, b) => a + b, 0) + 9
224+
const terminalWidth = process.stdout.columns || 120
225+
226+
if (totalWidth > terminalWidth && widths[2] > 20) {
227+
widths[2] = Math.max(20, widths[2] - (totalWidth - terminalWidth))
228+
}
229+
230+
const headerRow = headers.map((h, i) => h.padEnd(widths[i])).join(" | ")
231+
UI.println(headerRow)
232+
UI.println("-".repeat(headerRow.length))
233+
234+
rows.forEach((row, idx) => {
235+
const result = results[idx]
236+
const color = result.access ? UI.Style.TEXT_SUCCESS : UI.Style.TEXT_DANGER
237+
const truncatedRow = [row[0], row[1], row[2] ? truncate(row[2], widths[2]) : row[2], row[3]]
238+
const line = truncatedRow.map((c, i) => c.padEnd(widths[i])).join(" | ")
239+
UI.println(color + line + UI.Style.TEXT_NORMAL)
240+
})
241+
242+
const successful = results.filter((r) => r.access).length
243+
const failed = results.length - successful
244+
UI.println("")
245+
UI.println(
246+
`${UI.Style.TEXT_SUCCESS}${successful} accessible${UI.Style.TEXT_NORMAL}, ${UI.Style.TEXT_DANGER}${failed} failed${UI.Style.TEXT_NORMAL}`,
247+
)
248+
}
249+
},
250+
})
251+
}

packages/opencode/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { AgentCommand } from "./cli/cmd/agent"
88
import { UpgradeCommand } from "./cli/cmd/upgrade"
99
import { UninstallCommand } from "./cli/cmd/uninstall"
1010
import { ModelsCommand } from "./cli/cmd/models"
11+
import { RollCallCommand } from "./cli/cmd/roll-call"
1112
import { UI } from "./cli/ui"
1213
import { Installation } from "./installation"
1314
import { NamedError } from "@opencode-ai/util/error"
@@ -131,6 +132,7 @@ const cli = yargs(hideBin(process.argv))
131132
.command(ServeCommand)
132133
.command(WebCommand)
133134
.command(ModelsCommand)
135+
.command(RollCallCommand)
134136
.command(StatsCommand)
135137
.command(ExportCommand)
136138
.command(ImportCommand)

packages/opencode/src/provider/transform.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,18 @@ export namespace ProviderTransform {
779779
result["chat_template_args"] = { enable_thinking: true }
780780
}
781781

782+
// kilocode_change - Dedicated thinking models via Kilo Gateway require reasoning to be enabled.
783+
// Models with "thinking" in their ID are typically dedicated reasoning variants where
784+
// thinking cannot be disabled (e.g., kimi-k2-thinking). Enable reasoning explicitly to
785+
// avoid "Reasoning is mandatory for this endpoint" errors from OpenRouter.
786+
if (
787+
input.model.api.npm === "@kilocode/kilo-gateway" &&
788+
input.model.capabilities.reasoning &&
789+
input.model.api.id.includes("thinking")
790+
) {
791+
result["reasoning"] = { enabled: true }
792+
}
793+
782794
if (["zai", "zhipuai"].includes(input.model.providerID) && input.model.api.npm === "@ai-sdk/openai-compatible") {
783795
result["thinking"] = {
784796
type: "enabled",

0 commit comments

Comments
 (0)