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

Commit edafafb

Browse files
committed
feat: add roll-call command for batch-testing model connectivity
1 parent 4b09f7f commit edafafb

5 files changed

Lines changed: 591 additions & 0 deletions

File tree

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
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+
const HEADERS = ["Model", "Access", "Snippet", "Latency"]
13+
const SEPARATOR_PADDING = 9 // " | " between 4 columns = 3 * 3 = 9 chars
14+
15+
// Detect if stderr is a TTY for conditional color output
16+
const isTTY = process.stderr.isTTY ?? false
17+
18+
// Helper to conditionally apply colors only when output is to a TTY
19+
function color(style: string): string {
20+
return isTTY ? style : ""
21+
}
22+
23+
// Strip ANSI escape sequences and control characters for accurate width calculation
24+
function sanitize(text: string): string {
25+
return text
26+
.replace(/\x1b\[[0-9;]*m/g, "") // ANSI color codes
27+
.replace(/[\x00-\x1f\x7f]/g, "") // control characters (including \0, \n, etc.)
28+
}
29+
30+
function truncate(text: string, maxLen: number): string {
31+
if (maxLen < 4) return text.substring(0, maxLen)
32+
return text.length > maxLen ? text.substring(0, maxLen - 3) + "..." : text
33+
}
34+
35+
export function formatTable(
36+
rows: string[][],
37+
terminalWidth: number,
38+
): { header: string; separator: string; rows: string[] } {
39+
// Sanitize all cell content to strip control chars and ANSI codes
40+
const sanitizedRows = rows.map((row) => row.map((cell) => sanitize(cell ?? "")))
41+
42+
// Calculate natural width for each column based on sanitized content
43+
const widths = HEADERS.map((h, i) => Math.max(h.length, ...sanitizedRows.map((r) => r[i].length)))
44+
45+
// Total width with separators
46+
const totalWidth = widths.reduce((a, b) => a + b, 0) + SEPARATOR_PADDING
47+
48+
// Only shrink snippet column (index 2) if total exceeds terminal width
49+
// Minimum snippet width is header length (7) + 3 chars for meaningful content with "..."
50+
const minSnippetWidth = HEADERS[2].length + 3
51+
if (totalWidth > terminalWidth && widths[2] > minSnippetWidth) {
52+
const overflow = totalWidth - terminalWidth
53+
widths[2] = Math.max(minSnippetWidth, widths[2] - overflow)
54+
}
55+
56+
const header = HEADERS.map((h, i) => h.padEnd(widths[i])).join(" | ")
57+
const separator = "-".repeat(header.length)
58+
59+
const formattedRows = sanitizedRows.map((row) => {
60+
const truncatedRow = [row[0], row[1], row[2] ? truncate(row[2], widths[2]) : row[2], row[3]]
61+
return truncatedRow.map((c, i) => c.padEnd(widths[i])).join(" | ")
62+
})
63+
64+
return { header, separator, rows: formattedRows }
65+
}
66+
67+
export const RollCallCommand = cmd({
68+
command: "roll-call <filter>",
69+
describe: "batch-test models matching a filter for connectivity and latency",
70+
builder: (yargs: Argv) => {
71+
return yargs
72+
.positional("filter", {
73+
type: "string",
74+
describe: "regex to filter models by provider/modelID (required)",
75+
demandOption: true,
76+
})
77+
.option("prompt", {
78+
type: "string",
79+
default: "Hello",
80+
describe: "Prompt to send to each model",
81+
})
82+
.option("timeout", {
83+
type: "number",
84+
default: 25000,
85+
describe: "Timeout for each model call in milliseconds",
86+
})
87+
.option("parallel", {
88+
type: "number",
89+
default: 5,
90+
describe: "Number of parallel model calls",
91+
})
92+
.option("retries", {
93+
type: "number",
94+
default: 0,
95+
describe: "Number of additional retries for each model call",
96+
})
97+
.option("verbose", {
98+
type: "boolean",
99+
default: false,
100+
describe: "Show verbose output",
101+
})
102+
.option("quiet", {
103+
type: "boolean",
104+
default: false,
105+
describe: "Suppress non-error output",
106+
})
107+
.option("output", {
108+
type: "string",
109+
choices: ["table", "json"],
110+
default: "table",
111+
describe: "Output format",
112+
})
113+
},
114+
handler: async (args) => {
115+
await rollCallHandler(args)
116+
},
117+
})
118+
119+
interface RollCallResult {
120+
model: string
121+
access: boolean
122+
snippet: string
123+
latency: number | null
124+
errorType: string | null
125+
errorMessage: string | null
126+
}
127+
128+
export async function rollCallHandler(args: any) {
129+
const { prompt, timeout, filter, parallel, output, verbose, quiet } = args
130+
131+
if (!quiet) {
132+
UI.println(
133+
`${color(UI.Style.TEXT_INFO)}Starting roll call for models with prompt: "${prompt}"${color(UI.Style.TEXT_NORMAL)}`,
134+
)
135+
UI.println(
136+
`${color(UI.Style.TEXT_INFO)}Timeout per model: ${timeout}ms, Parallel calls: ${parallel}${color(UI.Style.TEXT_NORMAL)}`,
137+
)
138+
}
139+
140+
await Instance.provide({
141+
directory: process.cwd(),
142+
async fn() {
143+
const providers = await Provider.list()
144+
const modelsToTest: { providerID: string; modelID: string; model: Provider.Model }[] = []
145+
146+
for (const [providerID, provider] of Object.entries(providers)) {
147+
for (const [modelID, model] of Object.entries(provider.models)) {
148+
const fullName = `${providerID}/${modelID}`
149+
if (filter) {
150+
try {
151+
const regex = new RegExp(filter, "i")
152+
if (!regex.test(fullName)) continue
153+
} catch (e) {
154+
UI.error(`Invalid filter regex: ${filter}`)
155+
return
156+
}
157+
}
158+
modelsToTest.push({ providerID, modelID, model })
159+
}
160+
}
161+
162+
if (modelsToTest.length === 0) {
163+
if (!quiet)
164+
UI.println(`${color(UI.Style.TEXT_WARNING)}No models to test after filtering.${color(UI.Style.TEXT_NORMAL)}`)
165+
return
166+
}
167+
168+
if (!quiet) {
169+
UI.println(
170+
`${color(UI.Style.TEXT_INFO)}Prompting ${modelsToTest.length} models...${color(UI.Style.TEXT_NORMAL)}`,
171+
)
172+
}
173+
174+
const results: RollCallResult[] = []
175+
const queue = [...modelsToTest]
176+
const activePromises: Promise<void>[] = []
177+
178+
const processModel = async (item: (typeof modelsToTest)[0]) => {
179+
const { providerID, modelID, model } = item
180+
const fullName = `${providerID}/${modelID}`
181+
const startTime = Date.now()
182+
let access = false
183+
let snippet = ""
184+
let latency: number | null = null
185+
let errorType: string | null = null
186+
let errorMessage: string | null = null
187+
188+
try {
189+
const languageModel = await Provider.getLanguage(model)
190+
191+
// Build provider options similar to how session/index.ts does it
192+
const sessionID = randomUUID()
193+
const baseOptions = ProviderTransform.options({ model, sessionID })
194+
const providerOptions = ProviderTransform.providerOptions(model, baseOptions)
195+
const maxTokens = ProviderTransform.maxOutputTokens(model)
196+
const temperature = ProviderTransform.temperature(model)
197+
const topP = ProviderTransform.topP(model)
198+
const topK = ProviderTransform.topK(model)
199+
200+
const { text } = await generateText({
201+
model: languageModel,
202+
prompt,
203+
abortSignal: AbortSignal.timeout(timeout),
204+
maxOutputTokens: maxTokens,
205+
temperature,
206+
topP,
207+
topK,
208+
providerOptions,
209+
})
210+
access = true
211+
snippet = text.replace(/\n/g, " ")
212+
latency = Date.now() - startTime
213+
} catch (e: any) {
214+
latency = Date.now() - startTime
215+
if (e instanceof APICallError) {
216+
const parsedError = ProviderError.parseAPICallError({
217+
providerID,
218+
error: e,
219+
})
220+
errorType = parsedError.type
221+
errorMessage = parsedError.message
222+
} else {
223+
errorType = "unknown"
224+
errorMessage = e.message || "An unknown error occurred"
225+
}
226+
}
227+
228+
results.push({
229+
model: fullName,
230+
access,
231+
snippet,
232+
latency,
233+
errorType,
234+
errorMessage,
235+
})
236+
237+
if (verbose && !quiet) {
238+
if (access) {
239+
UI.println(`${color(UI.Style.TEXT_SUCCESS)}${color(UI.Style.TEXT_NORMAL)} ${fullName} - ${latency}ms`)
240+
} else {
241+
UI.println(
242+
`${color(UI.Style.TEXT_DANGER)}${color(UI.Style.TEXT_NORMAL)} ${fullName} - ${errorType}: ${errorMessage}`,
243+
)
244+
}
245+
}
246+
}
247+
248+
while (queue.length > 0 || activePromises.length > 0) {
249+
while (queue.length > 0 && activePromises.length < parallel) {
250+
const item = queue.shift()!
251+
const promise = processModel(item).finally(() => {
252+
const index = activePromises.indexOf(promise)
253+
if (index > -1) {
254+
activePromises.splice(index, 1)
255+
}
256+
})
257+
activePromises.push(promise)
258+
}
259+
if (activePromises.length > 0) {
260+
await Promise.race(activePromises)
261+
}
262+
}
263+
264+
if (quiet) return
265+
266+
if (output === "json") {
267+
console.log(JSON.stringify(results, null, 2))
268+
} else {
269+
const rows = results.map((r) => [
270+
r.model,
271+
r.access ? "YES" : "NO",
272+
r.access ? r.snippet : r.errorMessage ? `(${r.errorMessage})` : "",
273+
r.latency !== null ? `${r.latency}ms` : "N/A",
274+
])
275+
276+
const terminalWidth = parseInt(process.env.COLUMNS || "", 10) || process.stdout.columns || 80
277+
const table = formatTable(rows, terminalWidth)
278+
279+
UI.println(table.header)
280+
UI.println(table.separator)
281+
table.rows.forEach((line, idx) => {
282+
const rowColor = results[idx].access ? UI.Style.TEXT_SUCCESS : UI.Style.TEXT_DANGER
283+
UI.println(color(rowColor) + line + color(UI.Style.TEXT_NORMAL))
284+
})
285+
286+
const successful = results.filter((r) => r.access).length
287+
const failed = results.length - successful
288+
UI.println("")
289+
UI.println(
290+
`${color(UI.Style.TEXT_SUCCESS)}${successful} accessible${color(UI.Style.TEXT_NORMAL)}, ${color(UI.Style.TEXT_DANGER)}${failed} failed${color(UI.Style.TEXT_NORMAL)}`,
291+
)
292+
}
293+
},
294+
})
295+
}

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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,16 @@ export namespace ProviderTransform {
779779
result["chat_template_args"] = { enable_thinking: true }
780780
}
781781

782+
// kilocode_change - some thinking models (e.g. moonshot) require reasoning enabled
783+
// see test/provider/transform.test.ts "thinking models require reasoning enabled"
784+
if (
785+
input.model.api.npm === "@kilocode/kilo-gateway" &&
786+
input.model.capabilities.reasoning &&
787+
input.model.api.id.includes("thinking")
788+
) {
789+
result["reasoning"] = { enabled: true }
790+
}
791+
782792
if (["zai", "zhipuai"].includes(input.model.providerID) && input.model.api.npm === "@ai-sdk/openai-compatible") {
783793
result["thinking"] = {
784794
type: "enabled",

0 commit comments

Comments
 (0)