@openlines/opl-pi-sht 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +191 -0
- package/configs/opl-browser.json.sample +8 -0
- package/configs/opl-footer.json +48 -0
- package/configs/opl-footer.json.sample +48 -0
- package/configs/opl-input.json +22 -0
- package/configs/opl-input.json.sample +22 -0
- package/configs/opl-modes.json +291 -0
- package/configs/opl-modes.json.sample +285 -0
- package/configs/opl-simplebench.json +8 -0
- package/configs/opl-simplebench.json.sample +8 -0
- package/configs/opl-todo.json +12 -0
- package/configs/opl-todo.json.sample +12 -0
- package/configs/opl-webaccess.json +31 -0
- package/configs/opl-webaccess.json.sample +31 -0
- package/extensions/opl-browser/README.md +83 -0
- package/extensions/opl-browser/browser.ts +185 -0
- package/extensions/opl-browser/config.ts +37 -0
- package/extensions/opl-browser/index.ts +89 -0
- package/extensions/opl-browser/package-lock.json +60 -0
- package/extensions/opl-browser/package.json +10 -0
- package/extensions/opl-ctxtrim/README.md +78 -0
- package/extensions/opl-ctxtrim/index.ts +151 -0
- package/extensions/opl-footer/README.md +193 -0
- package/extensions/opl-footer/config.ts +91 -0
- package/extensions/opl-footer/git-status.ts +170 -0
- package/extensions/opl-footer/icons.ts +49 -0
- package/extensions/opl-footer/index.ts +368 -0
- package/extensions/opl-footer/segments/caveman.ts +25 -0
- package/extensions/opl-footer/segments/chat-mode.ts +24 -0
- package/extensions/opl-footer/segments/context.ts +92 -0
- package/extensions/opl-footer/segments/cost.ts +25 -0
- package/extensions/opl-footer/segments/git.ts +47 -0
- package/extensions/opl-footer/segments/helpers.ts +18 -0
- package/extensions/opl-footer/segments/index.ts +51 -0
- package/extensions/opl-footer/segments/mode-switcher.ts +24 -0
- package/extensions/opl-footer/segments/model.ts +22 -0
- package/extensions/opl-footer/segments/path.ts +34 -0
- package/extensions/opl-footer/segments/pi.ts +11 -0
- package/extensions/opl-footer/segments/plan-mode.ts +24 -0
- package/extensions/opl-footer/segments/separator.ts +9 -0
- package/extensions/opl-footer/segments/session-stats.ts +82 -0
- package/extensions/opl-footer/segments/thinking.ts +51 -0
- package/extensions/opl-footer/segments/tokens.ts +66 -0
- package/extensions/opl-footer/theme.ts +113 -0
- package/extensions/opl-footer/types.ts +172 -0
- package/extensions/opl-init/README.md +23 -0
- package/extensions/opl-init/index.ts +451 -0
- package/extensions/opl-input/README.md +83 -0
- package/extensions/opl-input/config.ts +137 -0
- package/extensions/opl-input/index.ts +284 -0
- package/extensions/opl-input/mode-style.ts +38 -0
- package/extensions/opl-input/utils.ts +378 -0
- package/extensions/opl-modes/README.md +132 -0
- package/extensions/opl-modes/config.ts +473 -0
- package/extensions/opl-modes/index.ts +1081 -0
- package/extensions/opl-modes/menus.ts +60 -0
- package/extensions/opl-modes/state.ts +152 -0
- package/extensions/opl-modes/types.ts +164 -0
- package/extensions/opl-modes/utils.ts +138 -0
- package/extensions/opl-questionnaire/README.md +26 -0
- package/extensions/opl-questionnaire/index.ts +445 -0
- package/extensions/opl-questionnaire/types.ts +55 -0
- package/extensions/opl-simplebench/README.md +242 -0
- package/extensions/opl-simplebench/artifact.ts +41 -0
- package/extensions/opl-simplebench/benchmark.ts +873 -0
- package/extensions/opl-simplebench/coding.ts +282 -0
- package/extensions/opl-simplebench/index.ts +203 -0
- package/extensions/opl-simplebench/llama-server.ts +290 -0
- package/extensions/opl-simplebench/metrics.ts +73 -0
- package/extensions/opl-simplebench/report.ts +46 -0
- package/extensions/opl-simplebench/research.ts +200 -0
- package/extensions/opl-simplebench/scoring.ts +48 -0
- package/extensions/opl-simplebench/tests.ts +39 -0
- package/extensions/opl-simplebench/types.ts +6 -0
- package/extensions/opl-simplebench/util/config.ts +170 -0
- package/extensions/opl-simplebench/util/debug.ts +32 -0
- package/extensions/opl-simplebench/util/format.ts +402 -0
- package/extensions/opl-simplebench/util/providers.ts +841 -0
- package/extensions/opl-todo/README.md +43 -0
- package/extensions/opl-todo/config.ts +71 -0
- package/extensions/opl-todo/index.ts +415 -0
- package/extensions/opl-webaccess/README.md +47 -0
- package/extensions/opl-webaccess/config.ts +55 -0
- package/extensions/opl-webaccess/extract.ts +83 -0
- package/extensions/opl-webaccess/index.ts +232 -0
- package/extensions/opl-webaccess/package-lock.json +345 -0
- package/extensions/opl-webaccess/package.json +18 -0
- package/extensions/opl-webaccess/pdf.ts +8 -0
- package/extensions/opl-webaccess/providers/ddgs.ts +72 -0
- package/extensions/opl-webaccess/providers/exa.ts +74 -0
- package/extensions/opl-webaccess/providers/gemini.ts +60 -0
- package/extensions/opl-webaccess/providers/searxng.ts +86 -0
- package/extensions/opl-webaccess/providers/tavily.ts +60 -0
- package/extensions/opl-webaccess/search.ts +36 -0
- package/extensions/opl-webaccess/storage.ts +51 -0
- package/extensions/opl-webaccess/turndown.d.ts +1 -0
- package/extensions/opl-webaccess/types.ts +26 -0
- package/extensions/opl-webaccess/utils.ts +28 -0
- package/package.json +55 -0
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
import * as crypto from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import { section, ok, fail, warn, info, msHuman, truncate, sanitizeForReport } from "./util/format";
|
|
4
|
+
import { getOllamaBaseUrl, detectProvider } from "./util/providers";
|
|
5
|
+
import { debugLog } from "./util/debug";
|
|
6
|
+
import { CONFIG, WEATHER_TOOL_DEFINITION, buildToolResultMessages, getEffectiveConfig, readTestConfig, type ChatFn, type ChatMessage } from "./util/config";
|
|
7
|
+
import { branding as sharedBranding, codingRecommendation, formatInstructionScore, formatTestSummary, recommendation } from "./report";
|
|
8
|
+
import { writeArtifact, writeArtifactBundle } from "./artifact";
|
|
9
|
+
import { aggregateMetrics, emptyMetrics, mergeRequestMetrics, metricsFromChat } from "./metrics";
|
|
10
|
+
import { REASONING_TESTS, MULTISTEP_INSTRUCTION, CALC_TOOL_DEFINITION } from "./tests";
|
|
11
|
+
import { CODING_LITE_TASKS, runCodingTask } from "./coding";
|
|
12
|
+
import { runGroundedResearchTask, runResearchArtifactTask, searchConfiguredResearch } from "./research";
|
|
13
|
+
import { scoreReasoning, averageScore } from "./scoring";
|
|
14
|
+
import { applyLlamagputop, applyLlamagputopModelStats, buildServerStats, fetchLlamaServerMetrics, fetchLlamaServerProps, fetchLlamagputopHealth, fetchLlamagputopStats, type ServerStats } from "./llama-server";
|
|
15
|
+
import type { SimplebenchOptions, TestRecord } from "./types";
|
|
16
|
+
|
|
17
|
+
type BenchmarkModel = { id: string; provider?: string; api?: string; reasoning?: boolean; thinkingLevelMap?: Record<string, string | null> };
|
|
18
|
+
type BenchmarkContext = { model?: BenchmarkModel; getScopedModels?: () => ReadonlyArray<{ model: BenchmarkModel }> };
|
|
19
|
+
|
|
20
|
+
type ThinkingMode = { requested: "default" | "max"; effective: "provider-default" | "openai-reasoning-effort" | "pi-bedrock-reasoning"; level: "max" | null };
|
|
21
|
+
|
|
22
|
+
export function buildToolContinuationMessages(initial: ChatMessage[], assistantContent: string, calls: any[], results: string[]): ChatMessage[] {
|
|
23
|
+
return [...initial, ...buildToolResultMessages(assistantContent, calls, results)];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function hasOllamaAssistantOutput(content: string, thinking: string, toolCalls: any[]): boolean {
|
|
27
|
+
return !!(content.trim() || thinking.trim() || toolCalls.length);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function resolveBenchmarkModel(ctx: BenchmarkContext | undefined, modelId: string): { model: BenchmarkModel | undefined; source: "active-context" | "scoped-model" | null } {
|
|
31
|
+
if (ctx?.model && (ctx.model.id === modelId || `${ctx.model.provider}/${ctx.model.id}` === modelId)) return { model: ctx.model, source: "active-context" };
|
|
32
|
+
const model = ctx?.getScopedModels?.().find(({ model }) => model.id === modelId || `${model.provider}/${model.id}` === modelId)?.model;
|
|
33
|
+
return { model, source: model ? "scoped-model" : null };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function resolveThinkingMode(providerInfo: { kind: string; apiMode?: string }, model: BenchmarkModel | undefined, thinkingMax: boolean): ThinkingMode {
|
|
37
|
+
if (!thinkingMax) return { requested: "default", effective: "provider-default", level: null };
|
|
38
|
+
if (providerInfo.apiMode === "openai-completions") return { requested: "max", effective: "openai-reasoning-effort", level: "max" };
|
|
39
|
+
if (providerInfo.kind === "bedrock") {
|
|
40
|
+
if (!model?.reasoning || !model.thinkingLevelMap?.max) throw new Error("The selected Bedrock model does not advertise max thinking");
|
|
41
|
+
return { requested: "max", effective: "pi-bedrock-reasoning", level: "max" };
|
|
42
|
+
}
|
|
43
|
+
throw new Error("--thinking-max is supported only for OpenAI-compatible and direct Bedrock models");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isValidInstructionOutput(output: string): boolean {
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(output.trim());
|
|
49
|
+
return !!parsed && !Array.isArray(parsed) && typeof parsed === "object"
|
|
50
|
+
&& Object.keys(parsed).length === 3
|
|
51
|
+
&& parsed.operation === "status"
|
|
52
|
+
&& parsed.requestId === "bench-42"
|
|
53
|
+
&& parsed.ok === true;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function openAiThinkingOptions(thinkingMax: boolean): Record<string, string> {
|
|
60
|
+
return thinkingMax ? { reasoning_effort: "max" } : {};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function createBenchmark() {
|
|
64
|
+
// Use effective config (user overrides merged with defaults)
|
|
65
|
+
const effectiveConfig = getEffectiveConfig();
|
|
66
|
+
|
|
67
|
+
// ── helpers ──────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get the current Ollama base URL.
|
|
71
|
+
* Re-reads on every call so /ollama-sync changes take effect immediately.
|
|
72
|
+
*/
|
|
73
|
+
function ollamaBase(): string {
|
|
74
|
+
return getOllamaBaseUrl();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Sleep for the configured test delay to avoid rate limiting.
|
|
79
|
+
*/
|
|
80
|
+
async function rateLimitDelay(): Promise<void> {
|
|
81
|
+
if (effectiveConfig.TEST_DELAY_MS > 0) {
|
|
82
|
+
await new Promise(r => setTimeout(r, effectiveConfig.TEST_DELAY_MS));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── ChatFn wrappers ──────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Create a chat function for Ollama API.
|
|
90
|
+
*/
|
|
91
|
+
function makeOllamaChatFn(useStreaming = true): ChatFn {
|
|
92
|
+
return async (model, messages, options) => {
|
|
93
|
+
const chatFn = useStreaming ? ollamaChatStream : ollamaChat;
|
|
94
|
+
const startedAt = new Date().toISOString();
|
|
95
|
+
const result = await chatFn(model, messages, options ?? {});
|
|
96
|
+
return {
|
|
97
|
+
content: result.response?.message?.content || "",
|
|
98
|
+
toolCalls: result.response?.message?.tool_calls,
|
|
99
|
+
elapsedMs: result.elapsedMs,
|
|
100
|
+
raw: result.response,
|
|
101
|
+
requestCount: result.requestCount,
|
|
102
|
+
retryCount: result.retryCount,
|
|
103
|
+
startedAt,
|
|
104
|
+
finishedAt: new Date().toISOString(),
|
|
105
|
+
timeToFirstTokenMs: result.timeToFirstTokenMs,
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Create a chat function for OpenAI-compatible API (OpenRouter, OpenAI, etc.).
|
|
112
|
+
*/
|
|
113
|
+
function makeOpenAiChatFn(baseUrl: string, apiKey?: string, thinkingMax = false): ChatFn {
|
|
114
|
+
return async (model, messages, options) => {
|
|
115
|
+
const tools = (options?.tools as any[] | undefined) || undefined;
|
|
116
|
+
const body: any = {
|
|
117
|
+
model,
|
|
118
|
+
messages,
|
|
119
|
+
stream: false,
|
|
120
|
+
cache: { "no-cache": true }, // bypass LiteLLM proxy response cache — benchmarks must measure real inference
|
|
121
|
+
...openAiThinkingOptions(thinkingMax),
|
|
122
|
+
};
|
|
123
|
+
if (tools && tools.length > 0) {
|
|
124
|
+
body.tools = tools.map((t: any) => ({
|
|
125
|
+
type: "function",
|
|
126
|
+
function: {
|
|
127
|
+
name: t.function?.name || t.name,
|
|
128
|
+
description: t.function?.description || t.description,
|
|
129
|
+
parameters: t.function?.parameters || t.parameters,
|
|
130
|
+
},
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
135
|
+
if (apiKey) {
|
|
136
|
+
headers["Authorization"] = `Bearer ${apiKey}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const controller = new AbortController();
|
|
140
|
+
const timeoutId = setTimeout(() => controller.abort(), CONFIG.TOOL_TEST_TIMEOUT_MS);
|
|
141
|
+
const start = Date.now();
|
|
142
|
+
try {
|
|
143
|
+
const res = await fetch(`${baseUrl}/chat/completions`, {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers,
|
|
146
|
+
body: JSON.stringify(body),
|
|
147
|
+
signal: controller.signal,
|
|
148
|
+
});
|
|
149
|
+
const elapsedMs = Date.now() - start;
|
|
150
|
+
clearTimeout(timeoutId);
|
|
151
|
+
|
|
152
|
+
if (!res.ok) {
|
|
153
|
+
const errorText = await res.text().catch(() => "unknown error");
|
|
154
|
+
throw new Error(`OpenAI API returned ${res.status}: ${truncate(errorText, 200)}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const parsed = await res.json();
|
|
158
|
+
const choice = parsed?.choices?.[0];
|
|
159
|
+
const content = choice?.message?.content || "";
|
|
160
|
+
const toolCalls = choice?.message?.tool_calls;
|
|
161
|
+
return {
|
|
162
|
+
content,
|
|
163
|
+
toolCalls: toolCalls && toolCalls.length > 0 ? toolCalls : undefined,
|
|
164
|
+
elapsedMs,
|
|
165
|
+
raw: parsed,
|
|
166
|
+
};
|
|
167
|
+
} catch (e: any) {
|
|
168
|
+
clearTimeout(timeoutId);
|
|
169
|
+
throw e;
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Resolve AWS credentials: env vars first, then the AWS CLI
|
|
176
|
+
* (`aws configure export-credentials`), which handles static keys,
|
|
177
|
+
* assume-role chains (source_profile + role_arn), and SSO.
|
|
178
|
+
* # ponytail: requires AWS CLI v2; cached per process
|
|
179
|
+
*/
|
|
180
|
+
let _awsCredsCache: { accessKeyId: string; secretAccessKey: string; sessionToken?: string } | null | undefined;
|
|
181
|
+
function resolveAwsCredentials(): { accessKeyId: string; secretAccessKey: string; sessionToken?: string } | null {
|
|
182
|
+
if (_awsCredsCache !== undefined) return _awsCredsCache;
|
|
183
|
+
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
|
|
184
|
+
_awsCredsCache = { accessKeyId: process.env.AWS_ACCESS_KEY_ID, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, sessionToken: process.env.AWS_SESSION_TOKEN };
|
|
185
|
+
return _awsCredsCache;
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
const prof = process.env.AWS_PROFILE || "default";
|
|
189
|
+
const out = require("node:child_process")
|
|
190
|
+
.execSync(`aws configure export-credentials --profile ${prof}`, { timeout: 15000 })
|
|
191
|
+
.toString();
|
|
192
|
+
const c = JSON.parse(out);
|
|
193
|
+
if (c.AccessKeyId && c.SecretAccessKey) {
|
|
194
|
+
_awsCredsCache = { accessKeyId: c.AccessKeyId, secretAccessKey: c.SecretAccessKey, sessionToken: c.SessionToken };
|
|
195
|
+
return _awsCredsCache;
|
|
196
|
+
}
|
|
197
|
+
} catch { /* aws CLI missing or profile unresolvable */ }
|
|
198
|
+
_awsCredsCache = null;
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Minimal SigV4 signer for a POST to the Bedrock Converse API. */
|
|
203
|
+
function sigv4Headers(
|
|
204
|
+
creds: { accessKeyId: string; secretAccessKey: string; sessionToken?: string },
|
|
205
|
+
host: string, urlPath: string, body: string, region: string,
|
|
206
|
+
): Record<string, string> {
|
|
207
|
+
const hmac = (key: Buffer | string, data: string) => crypto.createHmac("sha256", key).update(data).digest();
|
|
208
|
+
const sha256hex = (data: string) => crypto.createHash("sha256").update(data).digest("hex");
|
|
209
|
+
|
|
210
|
+
const amzDate = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
|
|
211
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
212
|
+
|
|
213
|
+
const lowerHeaders: Record<string, string> = { "content-type": "application/json", host, "x-amz-date": amzDate };
|
|
214
|
+
if (creds.sessionToken) lowerHeaders["x-amz-security-token"] = creds.sessionToken;
|
|
215
|
+
const signedHeadersList = Object.keys(lowerHeaders).sort();
|
|
216
|
+
const canonicalHeaders = signedHeadersList.map(h => `${h}:${lowerHeaders[h]}\n`).join("");
|
|
217
|
+
|
|
218
|
+
// ponytail: encodeURIComponent over-encodes slightly vs AWS's uri-encode, accepted by Converse in practice
|
|
219
|
+
const canonicalRequest = ["POST", urlPath, "", canonicalHeaders, signedHeadersList.join(";"), sha256hex(body)].join("\n");
|
|
220
|
+
const scope = `${dateStamp}/${region}/bedrock/aws4_request`;
|
|
221
|
+
const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256hex(canonicalRequest)].join("\n");
|
|
222
|
+
|
|
223
|
+
let k = hmac(`AWS4${creds.secretAccessKey}`, dateStamp);
|
|
224
|
+
k = hmac(k, region); k = hmac(k, "bedrock"); k = hmac(k, "aws4_request");
|
|
225
|
+
const signature = crypto.createHmac("sha256", k).update(stringToSign).digest("hex");
|
|
226
|
+
|
|
227
|
+
const headers: Record<string, string> = {
|
|
228
|
+
"Content-Type": "application/json",
|
|
229
|
+
"X-Amz-Date": amzDate,
|
|
230
|
+
Authorization: `AWS4-HMAC-SHA256 Credential=${creds.accessKeyId}/${scope}, SignedHeaders=${signedHeadersList.join(";")}, Signature=${signature}`,
|
|
231
|
+
};
|
|
232
|
+
if (creds.sessionToken) headers["X-Amz-Security-Token"] = creds.sessionToken;
|
|
233
|
+
return headers;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Chat function for amazon-bedrock via the Converse API (SigV4-signed).
|
|
238
|
+
*/
|
|
239
|
+
function makeBedrockChatFn(providerInfo: { region?: string }): ChatFn {
|
|
240
|
+
return async (model, messages, options) => {
|
|
241
|
+
const creds = resolveAwsCredentials();
|
|
242
|
+
if (!creds) throw new Error("AWS credentials not found — set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY or ~/.aws/credentials");
|
|
243
|
+
const region = providerInfo.region || process.env.AWS_REGION || "us-east-1";
|
|
244
|
+
const host = `bedrock-runtime.${region}.amazonaws.com`;
|
|
245
|
+
|
|
246
|
+
const system = messages.filter(m => m.role === "system").map(m => ({ text: m.content }));
|
|
247
|
+
const converse: any = {
|
|
248
|
+
...(system.length ? { system } : {}),
|
|
249
|
+
messages: messages.filter(m => m.role !== "system").map(m => ({ role: m.role, content: [{ text: m.content }] })),
|
|
250
|
+
};
|
|
251
|
+
const tools = (options?.tools as any[] | undefined) || undefined;
|
|
252
|
+
if (tools?.length) {
|
|
253
|
+
converse.toolConfig = {
|
|
254
|
+
tools: tools.map(t => ({
|
|
255
|
+
toolSpec: {
|
|
256
|
+
name: t.function?.name || t.name,
|
|
257
|
+
description: t.function?.description || t.description || "",
|
|
258
|
+
inputSchema: { json: t.function?.parameters || t.parameters || { type: "object", properties: {} } },
|
|
259
|
+
},
|
|
260
|
+
})),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
const body = JSON.stringify(converse);
|
|
264
|
+
const urlPath = `/model/${encodeURIComponent(model)}/converse`;
|
|
265
|
+
|
|
266
|
+
const controller = new AbortController();
|
|
267
|
+
const timeoutId = setTimeout(() => controller.abort(), CONFIG.TOOL_TEST_TIMEOUT_MS);
|
|
268
|
+
const start = Date.now();
|
|
269
|
+
try {
|
|
270
|
+
const res = await fetch(`https://${host}${urlPath}`, {
|
|
271
|
+
method: "POST",
|
|
272
|
+
headers: sigv4Headers(creds, host, urlPath, body, region),
|
|
273
|
+
body,
|
|
274
|
+
signal: controller.signal,
|
|
275
|
+
});
|
|
276
|
+
const elapsedMs = Date.now() - start;
|
|
277
|
+
if (!res.ok) {
|
|
278
|
+
const errorText = await res.text().catch(() => "unknown error");
|
|
279
|
+
throw new Error(`Bedrock API returned ${res.status}: ${truncate(errorText, 200)}`);
|
|
280
|
+
}
|
|
281
|
+
const parsed = await res.json();
|
|
282
|
+
const blocks = parsed?.output?.message?.content || [];
|
|
283
|
+
const content = blocks.map((b: any) => b.text || "").join("");
|
|
284
|
+
const toolCalls = blocks.filter((b: any) => b.toolUse).map((b: any) => ({
|
|
285
|
+
function: { name: b.toolUse.name, arguments: b.toolUse.input ?? {} },
|
|
286
|
+
}));
|
|
287
|
+
return { content, toolCalls: toolCalls.length > 0 ? toolCalls : undefined, elapsedMs, raw: parsed };
|
|
288
|
+
} catch (e: any) {
|
|
289
|
+
clearTimeout(timeoutId);
|
|
290
|
+
if (e.name === "AbortError") throw new Error(`Bedrock API timed out after ${msHuman(CONFIG.TOOL_TEST_TIMEOUT_MS)}`);
|
|
291
|
+
throw e;
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function makePiBedrockThinkingChatFn(modelConfig: BenchmarkModel): ChatFn {
|
|
297
|
+
return async (_model, messages, options) => {
|
|
298
|
+
const { streamSimple } = await import("@earendil-works/pi-ai/api/bedrock-converse-stream");
|
|
299
|
+
const systemPrompt = messages.filter(m => m.role === "system").map(m => m.content).join("\n") || undefined;
|
|
300
|
+
const tools = (options?.tools as any[] | undefined)?.map(tool => ({
|
|
301
|
+
name: tool.function?.name || tool.name,
|
|
302
|
+
description: tool.function?.description || tool.description || "",
|
|
303
|
+
parameters: tool.function?.parameters || tool.parameters || { type: "object", properties: {} },
|
|
304
|
+
}));
|
|
305
|
+
const startedAt = new Date().toISOString();
|
|
306
|
+
const start = Date.now();
|
|
307
|
+
const stream = streamSimple(modelConfig as any, {
|
|
308
|
+
...(systemPrompt ? { systemPrompt } : {}),
|
|
309
|
+
messages: messages.filter(m => m.role !== "system") as any,
|
|
310
|
+
...(tools?.length ? { tools } : {}),
|
|
311
|
+
}, { reasoning: "max" });
|
|
312
|
+
const message: any = await stream.result();
|
|
313
|
+
const content = (message.content || []).filter((block: any) => block.type === "text").map((block: any) => block.text).join("");
|
|
314
|
+
const toolCalls = (message.content || []).filter((block: any) => block.type === "toolCall").map((block: any) => ({
|
|
315
|
+
function: { name: block.name, arguments: block.arguments },
|
|
316
|
+
}));
|
|
317
|
+
return { content, toolCalls: toolCalls.length ? toolCalls : undefined, elapsedMs: Date.now() - start, raw: message, startedAt, finishedAt: new Date().toISOString() };
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Create the appropriate chat function based on provider type.
|
|
323
|
+
*/
|
|
324
|
+
function makeChatFn(providerInfo: { kind: string; name: string; baseUrl?: string; region?: string; apiKey?: string; apiMode?: string }, thinking: ThinkingMode, modelConfig: BenchmarkModel | undefined): ChatFn {
|
|
325
|
+
if (providerInfo.kind === "ollama") return makeOllamaChatFn();
|
|
326
|
+
if (providerInfo.kind === "bedrock") return thinking.effective === "pi-bedrock-reasoning" && modelConfig
|
|
327
|
+
? makePiBedrockThinkingChatFn(modelConfig)
|
|
328
|
+
: makeBedrockChatFn(providerInfo);
|
|
329
|
+
const baseUrl = providerInfo.baseUrl || ollamaBase();
|
|
330
|
+
return makeOpenAiChatFn(baseUrl, providerInfo.apiKey, thinking.effective === "openai-reasoning-effort");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function makeOllamaToolChatFn(): ChatFn {
|
|
334
|
+
return async (model, messages, options) => {
|
|
335
|
+
const tools = (options?.tools as any[] | undefined) || undefined;
|
|
336
|
+
const body: any = {
|
|
337
|
+
model,
|
|
338
|
+
messages,
|
|
339
|
+
stream: false,
|
|
340
|
+
};
|
|
341
|
+
if (tools && tools.length > 0) {
|
|
342
|
+
body.tools = tools;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const controller = new AbortController();
|
|
346
|
+
const timeoutId = setTimeout(() => controller.abort(), CONFIG.TOOL_TEST_TIMEOUT_MS);
|
|
347
|
+
const start = Date.now();
|
|
348
|
+
try {
|
|
349
|
+
const res = await fetch(`${ollamaBase()}/api/chat`, {
|
|
350
|
+
method: "POST",
|
|
351
|
+
headers: { "Content-Type": "application/json" },
|
|
352
|
+
body: JSON.stringify(body),
|
|
353
|
+
signal: controller.signal,
|
|
354
|
+
});
|
|
355
|
+
const elapsedMs = Date.now() - start;
|
|
356
|
+
clearTimeout(timeoutId);
|
|
357
|
+
|
|
358
|
+
if (!res.ok) {
|
|
359
|
+
const errorText = await res.text().catch(() => "unknown error");
|
|
360
|
+
throw new Error(`Ollama API returned ${res.status}: ${truncate(errorText, 200)}`);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const text = await res.text();
|
|
364
|
+
if (!text.trim()) throw new Error("Empty response from Ollama");
|
|
365
|
+
const parsed = JSON.parse(text);
|
|
366
|
+
const toolCalls = parsed?.message?.tool_calls;
|
|
367
|
+
const content = parsed?.message?.content || "";
|
|
368
|
+
return {
|
|
369
|
+
content,
|
|
370
|
+
toolCalls: toolCalls && toolCalls.length > 0 ? toolCalls : undefined,
|
|
371
|
+
elapsedMs,
|
|
372
|
+
raw: parsed,
|
|
373
|
+
};
|
|
374
|
+
} catch (e: any) {
|
|
375
|
+
clearTimeout(timeoutId);
|
|
376
|
+
throw e;
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ── Ollama Chat Functions ─────────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
async function ollamaChat(
|
|
384
|
+
model: string,
|
|
385
|
+
messages: Array<{ role: string; content: string }>,
|
|
386
|
+
options: Record<string, unknown> = {},
|
|
387
|
+
timeoutMs = CONFIG.DEFAULT_TIMEOUT_MS,
|
|
388
|
+
retries = CONFIG.MAX_RETRIES
|
|
389
|
+
): Promise<{ response: any; elapsedMs: number }> {
|
|
390
|
+
const { tools, ...generationOptions } = options as any;
|
|
391
|
+
const body: any = { model, messages, stream: false, ...(tools ? { tools } : {}), ...(Object.keys(generationOptions).length ? { options: generationOptions } : {}) };
|
|
392
|
+
const url = `${ollamaBase()}/api/chat`;
|
|
393
|
+
|
|
394
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
395
|
+
const start = Date.now();
|
|
396
|
+
const controller = new AbortController();
|
|
397
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
398
|
+
try {
|
|
399
|
+
const res = await fetch(url, {
|
|
400
|
+
method: "POST",
|
|
401
|
+
headers: { "Content-Type": "application/json" },
|
|
402
|
+
body: JSON.stringify(body),
|
|
403
|
+
signal: controller.signal,
|
|
404
|
+
});
|
|
405
|
+
const elapsedMs = Date.now() - start;
|
|
406
|
+
|
|
407
|
+
if (!res.ok) {
|
|
408
|
+
const errorText = await res.text().catch(() => "unknown error");
|
|
409
|
+
throw new Error(`Ollama API returned ${res.status}: ${truncate(errorText, 200)}`);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const text = await res.text();
|
|
413
|
+
if (!text.trim()) {
|
|
414
|
+
if (attempt < retries) {
|
|
415
|
+
await new Promise(r => setTimeout(r, CONFIG.RETRY_DELAY_MS));
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
throw new Error(`Empty response from Ollama after ${attempt + 1} attempt(s)`);
|
|
419
|
+
}
|
|
420
|
+
const parsed = JSON.parse(text);
|
|
421
|
+
return { response: parsed, elapsedMs };
|
|
422
|
+
} catch (e: unknown) {
|
|
423
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
424
|
+
if (e instanceof Error && e.name === "AbortError") {
|
|
425
|
+
if (attempt < retries) {
|
|
426
|
+
await new Promise(r => setTimeout(r, CONFIG.RETRY_DELAY_MS));
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
throw new Error(`Ollama API timed out after ${msHuman(timeoutMs)}`);
|
|
430
|
+
}
|
|
431
|
+
if (attempt < retries && (
|
|
432
|
+
msg.includes("Empty response") || msg.includes("ECONNREFUSED") ||
|
|
433
|
+
msg.includes("ECONNRESET") || msg.includes("fetch failed")
|
|
434
|
+
)) {
|
|
435
|
+
await new Promise(r => setTimeout(r, CONFIG.RETRY_DELAY_MS));
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
throw e;
|
|
439
|
+
} finally {
|
|
440
|
+
clearTimeout(timeoutId);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
throw new Error("Unreachable");
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function ollamaChatStream(
|
|
447
|
+
model: string,
|
|
448
|
+
messages: Array<{ role: string; content: string }>,
|
|
449
|
+
options: Record<string, unknown> = {},
|
|
450
|
+
timeoutMs = CONFIG.DEFAULT_TIMEOUT_MS,
|
|
451
|
+
): Promise<{ response: any; elapsedMs: number }> {
|
|
452
|
+
const { tools, ...generationOptions } = options as any;
|
|
453
|
+
const body: any = { model, messages, stream: true, ...(tools ? { tools } : {}), ...(Object.keys(generationOptions).length ? { options: generationOptions } : {}) };
|
|
454
|
+
const url = `${ollamaBase()}/api/chat`;
|
|
455
|
+
|
|
456
|
+
const controller = new AbortController();
|
|
457
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
458
|
+
const start = Date.now();
|
|
459
|
+
try {
|
|
460
|
+
const res = await fetch(url, {
|
|
461
|
+
method: "POST",
|
|
462
|
+
headers: { "Content-Type": "application/json" },
|
|
463
|
+
body: JSON.stringify(body),
|
|
464
|
+
signal: controller.signal,
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
if (!res.ok) {
|
|
468
|
+
const errorText = await res.text().catch(() => "unknown error");
|
|
469
|
+
throw new Error(`Ollama API returned ${res.status}: ${truncate(errorText, 200)}`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (!res.body) {
|
|
473
|
+
throw new Error("Ollama streaming response has no body");
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
let messageContent = "";
|
|
477
|
+
let thinkingContent = "";
|
|
478
|
+
const toolCalls: any[] = [];
|
|
479
|
+
let done = false;
|
|
480
|
+
|
|
481
|
+
const reader = res.body.getReader();
|
|
482
|
+
const decoder = new TextDecoder();
|
|
483
|
+
|
|
484
|
+
while (!done) {
|
|
485
|
+
const { value, done: streamDone } = await reader.read();
|
|
486
|
+
if (streamDone) break;
|
|
487
|
+
|
|
488
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
489
|
+
const lines = chunk.split("\n").filter((line: string) => line.trim().length > 0);
|
|
490
|
+
for (const line of lines) {
|
|
491
|
+
try {
|
|
492
|
+
const parsed = JSON.parse(line);
|
|
493
|
+
if (parsed.message?.content) messageContent += parsed.message.content;
|
|
494
|
+
if (parsed.message?.thinking) thinkingContent += parsed.message.thinking;
|
|
495
|
+
if (parsed.message?.tool_calls) toolCalls.push(...parsed.message.tool_calls);
|
|
496
|
+
if (parsed.done) done = true;
|
|
497
|
+
} catch (err) { debugLog("simplebench", "skipped malformed JSON chunk in streaming response", err); }
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const elapsedMs = Date.now() - start;
|
|
502
|
+
|
|
503
|
+
if (!hasOllamaAssistantOutput(messageContent, thinkingContent, toolCalls)) {
|
|
504
|
+
throw new Error("Empty streaming response from Ollama");
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const response = {
|
|
508
|
+
message: {
|
|
509
|
+
content: messageContent,
|
|
510
|
+
thinking: thinkingContent,
|
|
511
|
+
tool_calls: toolCalls.length ? toolCalls : undefined,
|
|
512
|
+
role: "assistant",
|
|
513
|
+
},
|
|
514
|
+
done: true,
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
return { response, elapsedMs };
|
|
518
|
+
} catch (e: unknown) {
|
|
519
|
+
if (e instanceof Error && e.name === "AbortError") {
|
|
520
|
+
throw new Error(`Ollama API timed out after ${msHuman(timeoutMs)}`);
|
|
521
|
+
}
|
|
522
|
+
throw e;
|
|
523
|
+
} finally {
|
|
524
|
+
clearTimeout(timeoutId);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ── Extended Test Functions ───────────────────────────────────────────
|
|
529
|
+
|
|
530
|
+
interface ReasoningTestResult {
|
|
531
|
+
name: string;
|
|
532
|
+
category: string;
|
|
533
|
+
prompt: string;
|
|
534
|
+
response: string | null;
|
|
535
|
+
error?: string;
|
|
536
|
+
elapsedMs: number;
|
|
537
|
+
metrics: ReturnType<typeof emptyMetrics>;
|
|
538
|
+
score: string;
|
|
539
|
+
answer: string;
|
|
540
|
+
expectedAnswer: string | string[];
|
|
541
|
+
pass: boolean;
|
|
542
|
+
details?: string;
|
|
543
|
+
}
|
|
544
|
+
type ProgressFn = (msg: string) => void;
|
|
545
|
+
|
|
546
|
+
async function testReasoningExtended(chatFn: ChatFn, model: string, onProgress?: ProgressFn): Promise<{ score: string; scores: string[]; answers: string[]; results: ReasoningTestResult[] }> {
|
|
547
|
+
const results: ReasoningTestResult[] = [];
|
|
548
|
+
const total = REASONING_TESTS.length;
|
|
549
|
+
for (let i = 0; i < total; i++) {
|
|
550
|
+
const test = REASONING_TESTS[i];
|
|
551
|
+
onProgress?.(`[1/3] Reasoning ${i + 1}/${total}: ${test.name} (${test.category})...`);
|
|
552
|
+
try {
|
|
553
|
+
const requestedAt = new Date().toISOString();
|
|
554
|
+
const result = await chatFn(model, [{ role: "user", content: test.prompt }]);
|
|
555
|
+
const msg = result.content.trim();
|
|
556
|
+
const scored = scoreReasoning(msg, test.expectedAnswer);
|
|
557
|
+
const answer = scored.answer;
|
|
558
|
+
results.push({ name: test.name, category: test.category, prompt: test.prompt, response: msg, elapsedMs: result.elapsedMs, metrics: metricsFromChat({ ...result, startedAt: result.startedAt ?? requestedAt, finishedAt: result.finishedAt ?? new Date().toISOString() }), score: scored.score, answer, expectedAnswer: test.expectedAnswer, pass: scored.pass, details: scored.details });
|
|
559
|
+
onProgress?.(`[1/3] Reasoning ${i + 1}/${total}: ${test.name} → ${scored.score}`);
|
|
560
|
+
} catch (e: any) {
|
|
561
|
+
results.push({ name: test.name, category: test.category, prompt: test.prompt, response: null, error: e?.message || String(e), elapsedMs: 0, metrics: emptyMetrics(), score: "ERROR", answer: "?", expectedAnswer: test.expectedAnswer, pass: false });
|
|
562
|
+
onProgress?.(`[1/3] Reasoning ${i + 1}/${total}: ${test.name} → ERROR`);
|
|
563
|
+
}
|
|
564
|
+
// Delay between reasoning tests to avoid rate limiting (skip after last test)
|
|
565
|
+
if (i < total - 1) await rateLimitDelay();
|
|
566
|
+
}
|
|
567
|
+
return { score: averageScore(results.map(r => r.score)), scores: results.map(r => r.score), answers: results.map(r => r.answer), results };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function testInstructionFollowingExtended(chatFn: ChatFn, model: string): Promise<{ pass: boolean; score: string; output: string; schemaValid: boolean; elapsedMs: number; metrics: ReturnType<typeof emptyMetrics> }> {
|
|
571
|
+
const start = Date.now();
|
|
572
|
+
try {
|
|
573
|
+
const result = await chatFn(model, [{ role: "user", content: MULTISTEP_INSTRUCTION }]);
|
|
574
|
+
const metrics = metricsFromChat({ ...result, startedAt: result.startedAt ?? new Date(start).toISOString(), finishedAt: result.finishedAt ?? new Date().toISOString() });
|
|
575
|
+
const output = result.content.trim();
|
|
576
|
+
const schemaValid = isValidInstructionOutput(output);
|
|
577
|
+
return { pass: schemaValid, score: schemaValid ? "STRONG" : "FAIL", output, schemaValid, elapsedMs: Date.now() - start, metrics };
|
|
578
|
+
} catch (e: any) {
|
|
579
|
+
return { pass: false, score: "FAIL", output: e.message, schemaValid: false, elapsedMs: Date.now() - start, metrics: emptyMetrics() };
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function canonicalizeExpression(value: unknown): string {
|
|
584
|
+
return String(value ?? "").replace(/\s+/g, "").replace(/[×x·∗]/g, "*").replace(/^\(+/, "").replace(/\)+$/, "").replace(/=+$/, "");
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function canonicalizeLocation(value: unknown): string {
|
|
588
|
+
return String(value ?? "").toLowerCase().replace(/[^a-z]+/g, " ");
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
async function testToolUsageExtended(chatFn: ChatFn, model: string, useToolResultMessages = true): Promise<{ pass: boolean; score: string; toolCalls: string[]; response: string; elapsedMs: number; metrics: ReturnType<typeof emptyMetrics> }> {
|
|
592
|
+
try {
|
|
593
|
+
const tools = [WEATHER_TOOL_DEFINITION, CALC_TOOL_DEFINITION];
|
|
594
|
+
const started = Date.now();
|
|
595
|
+
const initialMessages = [{ role: "system", content: "Use the available tools, then answer using their results." }, { role: "user", content: "What's weather in Tokyo and calculate 15*24?" }];
|
|
596
|
+
const first = await chatFn(model, initialMessages, { tools });
|
|
597
|
+
const calls = first.toolCalls || [];
|
|
598
|
+
const toolNames = calls.map((t: any) => t.function?.name || "?");
|
|
599
|
+
const results = calls.map((call: any) => {
|
|
600
|
+
const fn = call.function || call;
|
|
601
|
+
let args: any = {};
|
|
602
|
+
try { args = typeof fn.arguments === "string" ? JSON.parse(fn.arguments) : (fn.arguments || {}); } catch { return { name: fn.name, result: "INVALID_ARGUMENTS" }; }
|
|
603
|
+
if (fn.name === "get_weather" && canonicalizeLocation(args.location).split(" ").includes("tokyo")) return { name: fn.name, result: "Tokyo: clear, 22C" };
|
|
604
|
+
if (fn.name === "calculate" && canonicalizeExpression(args.expression) === "15*24") return { name: fn.name, result: "360" };
|
|
605
|
+
return { name: fn.name, result: "INVALID_ARGUMENTS" };
|
|
606
|
+
});
|
|
607
|
+
const validWeather = results.some(r => r.name === "get_weather" && r.result !== "INVALID_ARGUMENTS");
|
|
608
|
+
const validCalc = results.some(r => r.name === "calculate" && r.result === "360");
|
|
609
|
+
if (!calls.length) return { pass: false, score: "FAIL", toolCalls: toolNames, response: first.content, elapsedMs: Date.now() - started, metrics: metricsFromChat(first) };
|
|
610
|
+
if (!useToolResultMessages) {
|
|
611
|
+
return { pass: false, score: "FAIL", toolCalls: toolNames, response: first.content, elapsedMs: Date.now() - started, metrics: metricsFromChat(first) };
|
|
612
|
+
}
|
|
613
|
+
const followup = await chatFn(model, buildToolContinuationMessages(initialMessages, first.content, calls, results.map(result => result.result)), { tools });
|
|
614
|
+
const finalText = `${followup.content || ""}`.toLowerCase();
|
|
615
|
+
const synthesized = finalText.includes("360") && (finalText.includes("tokyo") || finalText.includes("22"));
|
|
616
|
+
const score = validWeather && validCalc && synthesized ? "STRONG" : validWeather || validCalc ? "MODERATE" : "WEAK";
|
|
617
|
+
return { pass: validWeather && validCalc && synthesized, score, toolCalls: toolNames, response: followup.content, elapsedMs: Date.now() - started, metrics: mergeRequestMetrics([metricsFromChat({ ...first, toolCalls: calls }), metricsFromChat({ ...followup, toolCalls: [] })]) };
|
|
618
|
+
} catch (e: any) {
|
|
619
|
+
return { pass: false, score: "ERROR", toolCalls: [], response: e.message, elapsedMs: 0, metrics: emptyMetrics() };
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async function testCodingLite(chatFn: ChatFn, model: string, onProgress?: (message: string) => void): Promise<{ results: Awaited<ReturnType<typeof runCodingTask>>[]; passed: number; total: number }> {
|
|
624
|
+
const results = [] as Awaited<ReturnType<typeof runCodingTask>>[];
|
|
625
|
+
for (let index = 0; index < CODING_LITE_TASKS.length; index += 1) {
|
|
626
|
+
const task = CODING_LITE_TASKS[index];
|
|
627
|
+
const prefix = `[${index + 1}/${CODING_LITE_TASKS.length}] coding-lite: [${task.id}]`;
|
|
628
|
+
onProgress?.(`${prefix} starting...`);
|
|
629
|
+
const result = await runCodingTask(chatFn, model, task, {
|
|
630
|
+
onProgress: message => onProgress?.(`${prefix} ${message.slice(task.id.length + 2)}`),
|
|
631
|
+
});
|
|
632
|
+
results.push(result);
|
|
633
|
+
onProgress?.(`${prefix} agent turn → ${result.passed ? "passed" : "failed"}`);
|
|
634
|
+
}
|
|
635
|
+
return { results, passed: results.filter(result => result.passed).length, total: results.length };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function codingTestRecords(coding: Awaited<ReturnType<typeof testCodingLite>>): TestRecord[] {
|
|
639
|
+
return coding.results.map(result => {
|
|
640
|
+
const task = CODING_LITE_TASKS.find(candidate => candidate.id === result.id)!;
|
|
641
|
+
return {
|
|
642
|
+
id: result.id,
|
|
643
|
+
kind: "coding" as const,
|
|
644
|
+
category: "coding-lite",
|
|
645
|
+
prompt: task.prompt,
|
|
646
|
+
response: null,
|
|
647
|
+
score: result.passed ? (result.efficiency ?? "STRONG") : "FAIL",
|
|
648
|
+
passed: result.passed,
|
|
649
|
+
error: result.error,
|
|
650
|
+
metrics: result.metrics,
|
|
651
|
+
coding: { publicPassed: result.publicPassed, hiddenPassed: result.hiddenPassed, verifiedAfterEdit: result.verifiedAfterEdit, unrelatedFiles: result.unrelatedFiles, toolCalls: result.toolCalls, turns: result.turns, efficiency: result.efficiency },
|
|
652
|
+
};
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ── get models to test ─────────────────────────────────────────────────
|
|
657
|
+
|
|
658
|
+
async function getOllamaModels(): Promise<string[]> {
|
|
659
|
+
try {
|
|
660
|
+
const res = await fetch(`${ollamaBase()}/api/tags`, { signal: AbortSignal.timeout(15000) });
|
|
661
|
+
if (!res.ok) return [];
|
|
662
|
+
const data = await res.json();
|
|
663
|
+
return (data.models || []).map((m: any) => m.name).filter(Boolean);
|
|
664
|
+
} catch (err) { debugLog("simplebench", "failed to list Ollama models", err); return []; }
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function getCurrentModel(ctx: any): string | undefined {
|
|
668
|
+
return ctx.model?.id;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// ── Extended Test Runner ───────────────────────────────────────────────
|
|
672
|
+
|
|
673
|
+
async function testModelExtended(model: string, ctx?: any, options: SimplebenchOptions = { allModels: false, writeArtifact: true }): Promise<string> {
|
|
674
|
+
const lines: string[] = [];
|
|
675
|
+
const totalStart = Date.now();
|
|
676
|
+
const providerInfo = ctx ? detectProvider(ctx) : { kind: "ollama" as const, name: "ollama" };
|
|
677
|
+
const resolvedModel = resolveBenchmarkModel(ctx, model);
|
|
678
|
+
const thinking = resolveThinkingMode(providerInfo, resolvedModel.model, options.thinkingMax === true);
|
|
679
|
+
const suite = options.testAll ? "test-all" : options.codingLite ? "coding-lite" : "baseline";
|
|
680
|
+
|
|
681
|
+
// Direct llama-server probes are separate from the provider route. This is
|
|
682
|
+
// intentional: --llamagputop also works when inference goes through LiteLLM.
|
|
683
|
+
const benchConfig = readTestConfig();
|
|
684
|
+
const captureEnabled = options.llamaServer === true || options.llamagputop === true;
|
|
685
|
+
const llamaBaseUrl = benchConfig.llamaServerUrl || "";
|
|
686
|
+
const llamaErrors: string[] = [];
|
|
687
|
+
let llamaProps: Awaited<ReturnType<typeof fetchLlamaServerProps>> | null = null;
|
|
688
|
+
let llamaMetricsBefore: Record<string, number> = {};
|
|
689
|
+
if (options.llamaServer) {
|
|
690
|
+
if (!llamaBaseUrl) llamaErrors.push("llamaServerUrl is not configured in opl-simplebench.json");
|
|
691
|
+
else llamaProps = await fetchLlamaServerProps(llamaBaseUrl);
|
|
692
|
+
if (llamaProps?.error) llamaErrors.push(llamaProps.error);
|
|
693
|
+
if (llamaBaseUrl) {
|
|
694
|
+
const before = await fetchLlamaServerMetrics(llamaBaseUrl);
|
|
695
|
+
if (before.error) llamaErrors.push(before.error);
|
|
696
|
+
llamaMetricsBefore = before.metrics;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
async function captureServerStats(): Promise<ServerStats | undefined> {
|
|
700
|
+
if (!captureEnabled) return undefined;
|
|
701
|
+
const errors = [...llamaErrors];
|
|
702
|
+
let stats = buildServerStats(llamaProps?.info ?? null, llamaMetricsBefore, {}, errors);
|
|
703
|
+
if (options.llamaServer && llamaBaseUrl) {
|
|
704
|
+
const after = await fetchLlamaServerMetrics(llamaBaseUrl);
|
|
705
|
+
if (after.error) errors.push(after.error);
|
|
706
|
+
stats = buildServerStats(llamaProps?.info ?? null, llamaMetricsBefore, after.metrics, errors);
|
|
707
|
+
}
|
|
708
|
+
if (options.llamagputop) {
|
|
709
|
+
const topUrl = benchConfig.llamagputopUrl;
|
|
710
|
+
if (!topUrl) errors.push("llamagputopUrl is not configured in opl-simplebench.json");
|
|
711
|
+
const external = topUrl ? await fetchLlamagputopStats(topUrl) : null;
|
|
712
|
+
if (external?.error) {
|
|
713
|
+
errors.push(external.error);
|
|
714
|
+
if (external.unavailable) {
|
|
715
|
+
const healthError = await fetchLlamagputopHealth(topUrl!);
|
|
716
|
+
if (healthError) errors.push(healthError);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
if (external?.info) {
|
|
720
|
+
stats.modelConfig = applyLlamagputop(stats.modelConfig, external.info.modelConfig);
|
|
721
|
+
stats.modelStats = applyLlamagputopModelStats(stats.modelStats, external.info.modelStats);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
lines.push(errors.length
|
|
725
|
+
? warn(`serverStats captured with warnings (${errors.join("; ")})`)
|
|
726
|
+
: info("serverStats captured"));
|
|
727
|
+
return stats;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
lines.push(sharedBranding);
|
|
731
|
+
lines.push(section(`MODEL: ${model}`));
|
|
732
|
+
lines.push(info(`Provider: ${providerInfo.name} (${providerInfo.kind})`));
|
|
733
|
+
lines.push(info(`Thinking: ${thinking.effective}`));
|
|
734
|
+
lines.push(info(`Suite: ${suite}`));
|
|
735
|
+
|
|
736
|
+
// Create chat functions for different test types
|
|
737
|
+
// Use provider-appropriate chat functions
|
|
738
|
+
const chatFn = makeChatFn(providerInfo, thinking, resolvedModel.model);
|
|
739
|
+
const toolChatFn = providerInfo.kind === "ollama" ? makeOllamaToolChatFn()
|
|
740
|
+
: providerInfo.kind === "bedrock" ? makeChatFn(providerInfo, thinking, resolvedModel.model)
|
|
741
|
+
: makeOpenAiChatFn(providerInfo.baseUrl || ollamaBase(), providerInfo.apiKey, thinking.effective === "openai-reasoning-effort");
|
|
742
|
+
|
|
743
|
+
// Progress notification helper — safe to call even without a TUI context
|
|
744
|
+
const progress = (msg: string) => ctx?.ui?.notify?.(msg, "info");
|
|
745
|
+
let codingSummary: Awaited<ReturnType<typeof testCodingLite>> | null = null;
|
|
746
|
+
let researchGrounded: Awaited<ReturnType<typeof runGroundedResearchTask>> | null = null;
|
|
747
|
+
let researchArtifact: Awaited<ReturnType<typeof runResearchArtifactTask>> | null = null;
|
|
748
|
+
|
|
749
|
+
if (options.codingLite || options.testAll) {
|
|
750
|
+
lines.push(section("CODING-LITE TEST"));
|
|
751
|
+
lines.push(info(`Testing ${CODING_LITE_TASKS.length} execution-backed coding tasks...`));
|
|
752
|
+
codingSummary = await testCodingLite(chatFn, model, progress);
|
|
753
|
+
for (const result of codingSummary.results) {
|
|
754
|
+
const eff = result.passed ? `${result.efficiency} (${result.turns} turn${result.turns !== 1 ? "s" : ""})` : "FAIL";
|
|
755
|
+
lines.push(result.passed ? ok(`✓ ${result.id}: ${eff}`) : fail(`✗ ${result.id}: ${eff} (${result.error || "hidden verification failed"})`));
|
|
756
|
+
}
|
|
757
|
+
lines.push(info(`Coding Lite: ${codingSummary.passed}/${codingSummary.total} passed — ${codingSummary.results.filter(r => r.efficiency === "STRONG").length} STRONG, ${codingSummary.results.filter(r => r.efficiency === "MODERATE").length} MODERATE, ${codingSummary.results.filter(r => r.efficiency === "WEAK").length} WEAK`));
|
|
758
|
+
if (options.codingLite && !options.testAll) {
|
|
759
|
+
const totalMs = Date.now() - totalStart;
|
|
760
|
+
let artifactPath: string | null = null;
|
|
761
|
+
const serverStats = await captureServerStats();
|
|
762
|
+
if (options.writeArtifact) {
|
|
763
|
+
try { artifactPath = writeArtifact({ schemaVersion: 1, benchmark: { name: "opl-simplebench", suite, ...(options.tag ? { tag: options.tag } : {}), model, provider: providerInfo.name, providerKind: providerInfo.kind, thinking: { ...thinking, modelMetadataSource: resolvedModel.source }, startedAt: new Date(totalStart).toISOString(), finishedAt: new Date().toISOString(), wallTimeMs: totalMs, artifactEnabled: true }, tests: codingTestRecords(codingSummary), summary: { coding: { passed: codingSummary.passed, total: codingSummary.total, efficiency: { strong: codingSummary.results.filter(r => r.efficiency === "STRONG").length, moderate: codingSummary.results.filter(r => r.efficiency === "MODERATE").length, weak: codingSummary.results.filter(r => r.efficiency === "WEAK").length, fail: codingSummary.results.filter(r => r.efficiency === "FAIL").length } }, ...(serverStats ? { serverStats } : {}) } }); }
|
|
764
|
+
catch (e: any) { lines.push(warn(`Artifact could not be written: ${e?.message || e}`)); }
|
|
765
|
+
}
|
|
766
|
+
const effCounts = { strong: codingSummary.results.filter(r => r.efficiency === "STRONG").length, moderate: codingSummary.results.filter(r => r.efficiency === "MODERATE").length, weak: codingSummary.results.filter(r => r.efficiency === "WEAK").length };
|
|
767
|
+
const codingOverall = codingRecommendation(codingSummary.passed, codingSummary.total);
|
|
768
|
+
lines.push(section("CODING-LITE RECOMMENDATION"));
|
|
769
|
+
lines.push(codingOverall.label === "WEAK" ? fail(`${model} is ${codingOverall.label} (${codingOverall.passed}/${codingOverall.total} passed — ${effCounts.strong} STRONG, ${effCounts.moderate} MODERATE, ${effCounts.weak} WEAK)`) : ok(`${model} is ${codingOverall.label} (${codingOverall.passed}/${codingOverall.total} passed — ${effCounts.strong} STRONG, ${effCounts.moderate} MODERATE, ${effCounts.weak} WEAK)`));
|
|
770
|
+
lines.push(info(artifactPath ? `Artifact: ${artifactPath}` : "Artifact: disabled (--no-artifact)"));
|
|
771
|
+
return lines.join("\n");
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
if (options.testAll) {
|
|
776
|
+
lines.push(section("GROUNDED RESEARCH TEST"));
|
|
777
|
+
lines.push(info("Testing fixed source cards, claim citations, minimalist UI guidance, and file artifacts..."));
|
|
778
|
+
researchGrounded = await runGroundedResearchTask(toolChatFn, model, { onProgress: message => progress(`[1/1] ${message}`) });
|
|
779
|
+
lines.push(researchGrounded.passed ? ok(`Grounded research: ${researchGrounded.score}`) : fail(`Grounded research: ${researchGrounded.score} (${researchGrounded.error})`));
|
|
780
|
+
}
|
|
781
|
+
if (options.researchLive) {
|
|
782
|
+
lines.push(section("LIVE RESEARCH SMOKE TEST"));
|
|
783
|
+
lines.push(info("Testing configured live search and artifact workflow; this does not affect recommendation..."));
|
|
784
|
+
researchArtifact = await runResearchArtifactTask(toolChatFn, model, { search: query => searchConfiguredResearch(query, benchConfig), onProgress: message => progress(`[live] ${message}`) });
|
|
785
|
+
lines.push(researchArtifact.passed ? ok(`Live research: ${researchArtifact.score}`) : fail(`Live research: ${researchArtifact.score} (${researchArtifact.error})`));
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// 1. Closed-answer contract test
|
|
789
|
+
lines.push(section("CLOSED-ANSWER CONTRACT TEST"));
|
|
790
|
+
lines.push(info(`Testing ${REASONING_TESTS.length} deterministic answer and final-line contracts...`));
|
|
791
|
+
const reasoning = await testReasoningExtended(chatFn, model, progress);
|
|
792
|
+
|
|
793
|
+
for (const r of reasoning.results) {
|
|
794
|
+
const passMark = r.pass ? "✅" : "❌";
|
|
795
|
+
const scoreLabel = r.score === "STRONG" ? ok : r.score === "MODERATE" ? warn : r.score === "WEAK" ? warn : fail;
|
|
796
|
+
lines.push(scoreLabel(`${passMark} ${r.name} (${r.category}): ${r.score} - expected "${r.expectedAnswer}", got "${r.answer}"${r.details ? ` [${r.details}]` : ""}`));
|
|
797
|
+
}
|
|
798
|
+
lines.push(ok(`Closed-answer contract score: ${reasoning.score}`));
|
|
799
|
+
|
|
800
|
+
// 2. Extended Instruction Following test
|
|
801
|
+
progress("[2/3] Instruction following test...");
|
|
802
|
+
lines.push(section("INSTRUCTION FOLLOWING TEST (EXTENDED)"));
|
|
803
|
+
lines.push(info("Testing multi-step JSON schema compliance..."));
|
|
804
|
+
await rateLimitDelay();
|
|
805
|
+
const instructions = await testInstructionFollowingExtended(chatFn, model);
|
|
806
|
+
lines.push(info(`Time: ${msHuman(instructions.elapsedMs)}`));
|
|
807
|
+
lines.push(formatInstructionScore(instructions));
|
|
808
|
+
lines.push(info(`Output: ${instructions.output}`));
|
|
809
|
+
|
|
810
|
+
// 3. Extended Tool Usage test
|
|
811
|
+
progress("[3/3] Tool usage test...");
|
|
812
|
+
lines.push(section("TOOL USAGE TEST (EXTENDED)"));
|
|
813
|
+
lines.push(info("Testing chained tool calls..."));
|
|
814
|
+
await rateLimitDelay();
|
|
815
|
+
const tools = await testToolUsageExtended(toolChatFn, model, providerInfo.kind !== "bedrock");
|
|
816
|
+
lines.push(info(`Time: ${msHuman(tools.elapsedMs)}`));
|
|
817
|
+
if (tools.score === "STRONG" || tools.score === "MODERATE") lines.push(ok(`Tool calls: ${tools.toolCalls.join(", ")} (${tools.score})`));
|
|
818
|
+
else lines.push(fail(`Tool calls: ${tools.toolCalls.length > 0 ? tools.toolCalls.join(", ") : "none"} (${tools.score})`));
|
|
819
|
+
lines.push(info(`Response: ${sanitizeForReport(tools.response)}`));
|
|
820
|
+
|
|
821
|
+
const totalMs = Date.now() - totalStart;
|
|
822
|
+
const artifactTests: TestRecord[] = reasoning.results.map(r => ({ id: r.name, kind: "reasoning", category: r.category, prompt: r.prompt, response: r.response, expectedAnswer: r.expectedAnswer, extractedAnswer: r.answer, score: r.score, passed: r.pass, error: r.error || null, metrics: r.metrics }));
|
|
823
|
+
artifactTests.push({ id: "instruction_following", kind: "instructions", prompt: MULTISTEP_INSTRUCTION, response: instructions.output, score: instructions.score, passed: instructions.pass, error: instructions.score === "FAIL" ? instructions.output : null, metrics: instructions.metrics });
|
|
824
|
+
artifactTests.push({ id: "tool_usage", kind: "tools", prompt: "What's weather in Tokyo and calculate 15*24?", response: tools.response, score: tools.score, passed: tools.pass, error: tools.score === "ERROR" ? tools.response : null, metrics: tools.metrics });
|
|
825
|
+
artifactTests.push(...(codingSummary ? codingTestRecords(codingSummary) : []));
|
|
826
|
+
if (researchGrounded) artifactTests.push({ id: researchGrounded.id, kind: "research-grounded", category: "research-grounded", prompt: "Research supplied urban-tree source cards and cite each required claim.", response: null, score: researchGrounded.score, passed: researchGrounded.passed, error: researchGrounded.error, metrics: researchGrounded.metrics, research: { toolCalls: researchGrounded.toolCalls, files: Object.keys(researchGrounded.files) } });
|
|
827
|
+
if (researchArtifact) artifactTests.push({ id: researchArtifact.id, kind: "research-live", category: "research-live", prompt: "Research benefits of urban trees and write research.md and page.html.", response: null, score: researchArtifact.score, passed: researchArtifact.passed, error: researchArtifact.error, metrics: researchArtifact.metrics, research: { toolCalls: researchArtifact.toolCalls, files: Object.keys(researchArtifact.files) } });
|
|
828
|
+
const aggregate = aggregateMetrics(artifactTests.filter(test => test.kind !== "coding"));
|
|
829
|
+
let artifactPath: string | null = null;
|
|
830
|
+
const serverStats = await captureServerStats();
|
|
831
|
+
if (options.writeArtifact) {
|
|
832
|
+
try {
|
|
833
|
+
const artifact = { schemaVersion: 1 as const, benchmark: { name: "opl-simplebench" as const, suite, ...(options.tag ? { tag: options.tag } : {}), model, provider: providerInfo.name, providerKind: providerInfo.kind, thinking: { ...thinking, modelMetadataSource: resolvedModel.source }, startedAt: new Date(totalStart).toISOString(), finishedAt: new Date().toISOString(), wallTimeMs: totalMs, artifactEnabled: true }, tests: artifactTests, summary: { reasoning: { score: reasoning.score, passed: reasoning.results.filter(r => r.pass).length, total: reasoning.results.length }, instructions: instructions.score, tools: tools.score, ...(codingSummary ? { coding: { passed: codingSummary.passed, total: codingSummary.total, efficiency: { strong: codingSummary.results.filter(r => r.efficiency === "STRONG").length, moderate: codingSummary.results.filter(r => r.efficiency === "MODERATE").length, weak: codingSummary.results.filter(r => r.efficiency === "WEAK").length, fail: codingSummary.results.filter(r => r.efficiency === "FAIL").length } } } : {}), ...(researchGrounded ? { researchGrounded: researchGrounded.score } : {}), ...(researchArtifact ? { researchLive: researchArtifact.score } : {}), metrics: aggregate, ...(serverStats ? { serverStats } : {}) } };
|
|
834
|
+
artifactPath = researchGrounded ? writeArtifactBundle(artifact, researchGrounded.files) : writeArtifact(artifact);
|
|
835
|
+
} catch (e: any) { lines.push(warn(`Artifact could not be written: ${e?.message || e}`)); }
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const reasoningPassed = reasoning.results.filter(r => r.pass).length;
|
|
839
|
+
const reasoningTotal = reasoning.results.length;
|
|
840
|
+
const instructionPassed = instructions.pass ? 1 : 0;
|
|
841
|
+
const toolPassed = tools.pass ? 1 : 0;
|
|
842
|
+
const totalPassed = reasoningPassed + instructionPassed + toolPassed;
|
|
843
|
+
const totalTests = reasoningTotal + 1 + 1;
|
|
844
|
+
|
|
845
|
+
const summaryTests = [
|
|
846
|
+
{ name: "Closed-answer contract", pass: reasoning.score === "STRONG" || reasoning.score === "MODERATE", score: reasoning.score },
|
|
847
|
+
{ name: "Instructions", pass: instructions.pass, score: instructions.score },
|
|
848
|
+
{ name: "Tool Usage", pass: tools.pass, score: tools.score },
|
|
849
|
+
...(codingSummary ? [{ name: "Coding Lite", pass: codingSummary.passed >= Math.ceil(codingSummary.total / 2), score: `${codingSummary.passed}/${codingSummary.total} (${codingSummary.results.filter(r => r.efficiency === "STRONG").length}× STRONG)` }] : []),
|
|
850
|
+
...(researchGrounded ? [{ name: "Grounded Research", pass: researchGrounded.passed, score: researchGrounded.score }] : []),
|
|
851
|
+
...(researchArtifact ? [{ name: "Live Research", pass: researchArtifact.passed, score: researchArtifact.score }] : []),
|
|
852
|
+
];
|
|
853
|
+
lines.push(...formatTestSummary(summaryTests, totalMs));
|
|
854
|
+
|
|
855
|
+
lines.push("");
|
|
856
|
+
lines.push(info(`Detailed: Closed-answer contract ${reasoningPassed}/${reasoningTotal} tests passed, Instructions ${instructionPassed}/1, Tool Usage ${toolPassed}/1${codingSummary ? `, Coding Lite ${codingSummary.passed}/${codingSummary.total}` : ""}${researchGrounded ? `, Grounded Research ${researchGrounded.score}` : ""}${researchArtifact ? `, Live Research ${researchArtifact.score}` : ""}`));
|
|
857
|
+
const categoryRecommendation = recommendation(reasoning.score, instructions.pass, tools.pass, codingSummary ? { ...codingSummary, efficiency: { strong: codingSummary.results.filter(r => r.efficiency === "STRONG").length, moderate: codingSummary.results.filter(r => r.efficiency === "MODERATE").length, weak: codingSummary.results.filter(r => r.efficiency === "WEAK").length, fail: codingSummary.results.filter(r => r.efficiency === "FAIL").length } } : undefined);
|
|
858
|
+
lines.push(section("RECOMMENDATION"));
|
|
859
|
+
lines.push(categoryRecommendation.label === "WEAK" ? fail(`${model} is ${categoryRecommendation.label}`) : ok(`${model} is ${categoryRecommendation.label}`));
|
|
860
|
+
if (codingSummary) lines.push(info(`Coding Lite contribution: ${codingSummary.passed}/${codingSummary.total} tasks`));
|
|
861
|
+
lines.push(info(artifactPath ? `Artifact: ${artifactPath}` : "Artifact: disabled (--no-artifact)"));
|
|
862
|
+
return lines.join("\n");
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Main entry point: always runs the extended test.
|
|
867
|
+
*/
|
|
868
|
+
async function testModel(model: string, ctx?: any, options?: SimplebenchOptions): Promise<string> {
|
|
869
|
+
return testModelExtended(model, ctx, options);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
return { getOllamaModels, testModel, testToolUsageExtended };
|
|
873
|
+
}
|