Skip to content

Commit bc0b028

Browse files
committed
chore: add Copilot Responses API probe script
1 parent 9aef2af commit bc0b028

File tree

1 file changed

+264
-0
lines changed

1 file changed

+264
-0
lines changed
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
#!/usr/bin/env bun
2+
3+
import path from "path"
4+
import { fileURLToPath } from "url"
5+
import { streamText, type ModelMessage } from "ai"
6+
7+
const __filename = fileURLToPath(import.meta.url)
8+
const __dirname = path.dirname(__filename)
9+
const pkgDir = path.resolve(__dirname, "..")
10+
11+
process.chdir(pkgDir)
12+
13+
type Args = {
14+
providerIDs: string[]
15+
timeoutMs: number
16+
delayMs: number
17+
limit: number | undefined
18+
includeMini: boolean
19+
}
20+
21+
function parseArgs(argv: string[]): Args {
22+
const providerIDs: string[] = []
23+
let timeoutMs = 20_000
24+
let delayMs = 250
25+
let limit: number | undefined
26+
let includeMini = false
27+
28+
for (let i = 0; i < argv.length; i++) {
29+
const arg = argv[i]
30+
switch (arg) {
31+
case "--provider":
32+
case "-p": {
33+
const value = argv[++i]
34+
if (!value) throw new Error("Missing value for --provider")
35+
providerIDs.push(value)
36+
break
37+
}
38+
case "--timeout-ms": {
39+
const value = argv[++i]
40+
if (!value) throw new Error("Missing value for --timeout-ms")
41+
timeoutMs = Number(value)
42+
break
43+
}
44+
case "--delay-ms": {
45+
const value = argv[++i]
46+
if (!value) throw new Error("Missing value for --delay-ms")
47+
delayMs = Number(value)
48+
break
49+
}
50+
case "--limit": {
51+
const value = argv[++i]
52+
if (!value) throw new Error("Missing value for --limit")
53+
limit = Number(value)
54+
break
55+
}
56+
case "--include-mini": {
57+
includeMini = true
58+
break
59+
}
60+
case "--help":
61+
case "-h": {
62+
printHelp()
63+
process.exit(0)
64+
}
65+
}
66+
}
67+
68+
if (providerIDs.length === 0) {
69+
providerIDs.push("github-copilot")
70+
}
71+
72+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("--timeout-ms must be > 0")
73+
if (!Number.isFinite(delayMs) || delayMs < 0) throw new Error("--delay-ms must be >= 0")
74+
if (limit !== undefined && (!Number.isFinite(limit) || limit <= 0)) throw new Error("--limit must be > 0")
75+
76+
return { providerIDs, timeoutMs, delayMs, limit, includeMini }
77+
}
78+
79+
function printHelp() {
80+
process.stdout.write(
81+
[
82+
"Verify GitHub Copilot model support for OpenAI Responses API routing.",
83+
"",
84+
"This script runs real requests against the GitHub Copilot backend.",
85+
"Results can vary by account/region/feature flags.",
86+
"",
87+
"Usage:",
88+
" bun run script/verify-copilot-responses.ts [options]",
89+
"",
90+
"Options:",
91+
" -p, --provider <id> Provider ID to test (repeatable). Default: github-copilot",
92+
" --timeout-ms <n> Per-model timeout (default: 20000)",
93+
" --delay-ms <n> Delay between models (default: 250)",
94+
" --limit <n> Only test the first N models per provider",
95+
" --include-mini Also test gpt-5-mini (expected to fail for some users)",
96+
" -h, --help Show help",
97+
"",
98+
"Notes:",
99+
"- The script forces provider.github-copilot.options.useResponsesApi=true via OPENCODE_CONFIG_CONTENT.",
100+
"- Ensure you're authenticated for GitHub Copilot in OpenCode before running.",
101+
].join("\n"),
102+
)
103+
process.stdout.write("\n")
104+
}
105+
106+
function sleep(ms: number) {
107+
return new Promise<void>((resolve) => setTimeout(resolve, ms))
108+
}
109+
110+
function getGptMajor(modelID: string): number | undefined {
111+
const match = /^gpt-(\d+)/.exec(modelID)
112+
if (!match) return undefined
113+
return Number(match[1])
114+
}
115+
116+
function buildConfigOverlayJSON(providerID: string): string {
117+
// Merge into any existing OPENCODE_CONFIG_CONTENT the user provided.
118+
const existingRaw = process.env.OPENCODE_CONFIG_CONTENT
119+
const existing = existingRaw ? safeJsonParse(existingRaw) : {}
120+
const overlay = {
121+
provider: {
122+
[providerID]: {
123+
options: {
124+
useResponsesApi: true,
125+
},
126+
},
127+
},
128+
}
129+
return JSON.stringify(deepMerge(existing, overlay))
130+
}
131+
132+
function safeJsonParse(text: string): any {
133+
try {
134+
return JSON.parse(text)
135+
} catch {
136+
return {}
137+
}
138+
}
139+
140+
function isPlainObject(value: any): value is Record<string, any> {
141+
return Boolean(value) && typeof value === "object" && !Array.isArray(value)
142+
}
143+
144+
function deepMerge(a: any, b: any): any {
145+
if (isPlainObject(a) && isPlainObject(b)) {
146+
const out: Record<string, any> = { ...a }
147+
for (const [key, value] of Object.entries(b)) {
148+
out[key] = deepMerge(out[key], value)
149+
}
150+
return out
151+
}
152+
return b
153+
}
154+
155+
async function probeModel(input: {
156+
providerID: string
157+
modelID: string
158+
timeoutMs: number
159+
}): Promise<{ ok: boolean; error?: string }> {
160+
const messages: ModelMessage[] = [
161+
{
162+
role: "user",
163+
content: "ping",
164+
},
165+
]
166+
167+
try {
168+
const { Provider } = await import("../src/provider/provider")
169+
const model = await Provider.getModel(input.providerID, input.modelID)
170+
const language = await Provider.getLanguage(model)
171+
172+
const stream = await streamText({
173+
model: language,
174+
messages,
175+
maxOutputTokens: 8,
176+
abortSignal: AbortSignal.timeout(input.timeoutMs),
177+
})
178+
179+
for await (const part of stream.fullStream) {
180+
// Any successful stream event is enough to consider the route supported.
181+
if (part.type === "text-delta" || part.type === "reasoning-delta" || part.type === "start") {
182+
break
183+
}
184+
}
185+
186+
return { ok: true }
187+
} catch (e: any) {
188+
const message = e?.message ? String(e.message) : String(e)
189+
return { ok: false, error: message }
190+
}
191+
}
192+
193+
async function main() {
194+
const args = parseArgs(process.argv.slice(2))
195+
196+
const { Instance } = await import("../src/project/instance")
197+
const { Provider } = await import("../src/provider/provider")
198+
199+
process.stdout.write("# Copilot Responses API Probe\n")
200+
process.stdout.write(`Providers: ${args.providerIDs.join(", ")}\n`)
201+
process.stdout.write(`Timeout: ${args.timeoutMs}ms, Delay: ${args.delayMs}ms\n\n`)
202+
203+
await Instance.provide({
204+
directory: process.cwd(),
205+
async fn() {
206+
for (const providerID of args.providerIDs) {
207+
// Force the opt-in flag for this provider.
208+
process.env.OPENCODE_CONFIG_CONTENT = buildConfigOverlayJSON(providerID)
209+
210+
// Reload provider state by importing after env var is set.
211+
// Provider uses Instance.state caching, so the safest approach is to filter the already-loaded
212+
// provider models list and just probe calls (routing is decided per-model loader).
213+
const provider = await Provider.getProvider(providerID)
214+
if (!provider) {
215+
process.stdout.write(`\n## ${providerID}\n`)
216+
process.stdout.write("Provider not found in current config / environment.\n")
217+
continue
218+
}
219+
220+
const modelIDs = Object.keys(provider.models)
221+
.filter((id) => {
222+
const major = getGptMajor(id)
223+
if (major === undefined || major < 5) return false
224+
if (!args.includeMini && id === "gpt-5-mini") return false
225+
return true
226+
})
227+
.sort((a, b) => a.localeCompare(b))
228+
229+
const selected = args.limit ? modelIDs.slice(0, args.limit) : modelIDs
230+
231+
process.stdout.write(`\n## ${providerID}\n`)
232+
process.stdout.write(`Testing ${selected.length} model(s)\n`)
233+
234+
let okCount = 0
235+
let failCount = 0
236+
237+
for (const modelID of selected) {
238+
process.stdout.write(`- ${providerID}/${modelID}: `)
239+
const result = await probeModel({
240+
providerID,
241+
modelID,
242+
timeoutMs: args.timeoutMs,
243+
})
244+
245+
if (result.ok) {
246+
okCount++
247+
process.stdout.write("OK\n")
248+
} else {
249+
failCount++
250+
process.stdout.write(`FAIL (${result.error})\n`)
251+
}
252+
253+
if (args.delayMs > 0) {
254+
await sleep(args.delayMs)
255+
}
256+
}
257+
258+
process.stdout.write(`Summary: ${okCount} OK, ${failCount} FAIL\n`)
259+
}
260+
},
261+
})
262+
}
263+
264+
await main()

0 commit comments

Comments
 (0)