openpencil/scripts/ab-corpus/real-model.ts
Fini 95566e4ed2 feat(ab-corpus): bootstrap ab-v3 with token cost + composite difficulty
ab-v3 succeeds ab-v1 (frozen 2026-04-28). Carries forward all 40
v1 obvious yaml files unchanged so the v1↔v3 overlap stays
comparable, then layers in two new dimensions.

**1. Token cost.** All clients (openai-compat, ark, bailian,
deepseek, minimax, codex-cli, stub-model) now return a
`ChatCallResult { content, usage }` instead of bare string.
Provider usage stats (`prompt_tokens` / `completion_tokens`) plumb
through realModelCall → run.ts → scoreRun → ScoreRow.{prompt,completion}Tokens.
aggregate adds avgPromptTokens{Baseline,Treatment} +
avgCompletionTokens{Baseline,Treatment} per ModelSummary.
write-report emits a new "Token cost" table with Δ columns so
narrow-tools-saves-tokens (the ab-v2 hypothesis) is measurable.
avgUsage skips rows with 0/0 usage so codex-cli (CLI doesn't
surface tokens) and harness errors don't deflate the average to
near-zero — they show '—' instead.

**2. Composite difficulty.** New 'composite' value alongside
obvious / optional. Composite prompts express multi-tool intents
where no single expected_tool_if_any applies. classifyRouting
routes composite-treatment runs into multi-tool / fallback /
garbage (3-bucket sum to 1, distinct from obvious's 4-bucket
right/wrong/fallback/garbage). aggregate adds m6_multi_tool +
m6_fallback + m6_garbage; write-report emits a "Composite routing"
table that gracefully degrades to a placeholder when no composite
yaml exists yet.

Harness side: scripts/ab-corpus/run.ts accepts --corpus ab-v3
(enum + parseArgs guard); dry-run on the v1-mirror corpus produces
a 160-row report including populated token table.

Tests: 4 new aggregate cases (composite, token avg with skip-zero,
NaN-when-no-data) + 4 new score-run cases (composite routing
multi-tool/fallback/garbage/baseline-n/a) + 2 new score-run cases
(usage plumbing) + 2 new openai-compat cases (usage parsing,
missing-usage fallback). Existing 5 retry tests updated for new
return shape. 3727 → 3740 vitest tests, all green; tsc + format
clean.

Token-cost docs and composite docs go straight into types.ts /
score-run.ts / aggregate.ts JSDoc — keeps the contract close to
the code that owns it.
2026-04-29 07:45:00 +08:00

205 lines
8 KiB
TypeScript

/**
* Live model dispatcher. Picks the right client per model id and
* invokes it with the full system + user prompt. Exposed as
* `realModelCall` with the same signature as `stubModelCall` so the
* harness swaps them behind the `--live` flag without conditional
* logic in the main loop.
*
* Router rules:
* - `minimax*` → clients/minimax.ts (needs MINIMAX_API_KEY in env)
* - `gpt-*` / `o1` / `o3` / `o4` → clients/codex-cli.ts (uses
* Codex CLI subscription)
* - anything else → throw with the supported list
*/
import { callMinimax } from './clients/minimax';
import { callCodex } from './clients/codex-cli';
import { callBailian } from './clients/bailian';
import { callArk } from './clients/ark';
import { callDeepSeek } from './clients/deepseek';
import type { ChatCallResult } from './clients/openai-compat';
import { buildSystemPrompt } from './build-prompt';
import type { ModelCall } from './stub-model';
/**
* Router rules (checked in priority order, first match wins):
*
* - `minimax*` → clients/minimax.ts (MINIMAX_API_KEY)
* - `gpt-*` / o* → clients/codex-cli.ts (Codex CLI subscription)
* - `glm-5.1` → clients/ark.ts (ARK_CODING_KEY;
* Volcengine 方舟 now hosts
* GLM-5.1 on its coding plan
* as of 2026-04-22. Replaces
* the earlier GLM-official
* CP route via open.bigmodel.cn
* — clients/glm.ts is kept on
* disk for historical reference
* but no longer auto-routed.)
* - `kimi-k2.6` → clients/ark.ts (ARK_CODING_KEY; Volcengine
* added K2.6 to 方舟 CP on
* 2026-04-22)
* - `glm-*` → clients/bailian.ts (DASHSCOPE_BAILIAN_CODING_KEY,
* earlier GLM versions hosted on
* Bailian's DashScope aggregator)
* - `kimi-*` → clients/bailian.ts (DASHSCOPE_BAILIAN_CODING_KEY,
* Kimi K2.5 and earlier on Bailian)
*
* Anything else → throw with the supported list.
*
* When benchmarking across routes (e.g. to compare the new Ark GLM-5.1
* vs. the legacy GLM-official route), import `callGlm` from
* `./clients/glm.ts` directly and wire it here under a different model
* alias (say `glm-5.1-legacy`). The default router picks Ark because
* that's the path we're actively promoting.
*/
export async function realModelCall(call: ModelCall): Promise<ChatCallResult> {
const built = buildSystemPrompt(call.variant);
const user = call.prompt.prompt;
const model = call.model;
if (/^minimax/i.test(model)) {
return callMinimax({
model: mapMinimaxId(model),
system: built.system,
user,
});
}
if (/^(gpt-|o1|o3|o4)/i.test(model)) {
return callCodex({ model, system: built.system, user });
}
// Anchored match: `glm-5.1`, `glm-5.1-coding`, `glm-5.1-ark`.
// Without `$` + suffix allowlist, `glm-5.10` (a hypothetical later
// minor) would prefix-match and wrongly route here.
if (/^glm-5\.1(-coding|-ark)?$/i.test(model)) {
return callArk({
model: mapGlmArkId(model),
system: built.system,
user,
});
}
// Match both `kimi-k2.6` (canonical) and `kimi-2.6` (no-prefix
// alias). Without the optional `k` the alias falls through to the
// generic `/^kimi/i` branch below and wrongly lands on Bailian
// (which doesn't serve K2.6). Trailing `-ark` suffix also allowed.
if (/^kimi-k?2\.6(-ark)?$/i.test(model)) {
return callArk({
model: mapKimiArkId(model),
system: built.system,
user,
});
}
if (/^glm-/i.test(model)) {
return callBailian({
model: mapGlmBailianId(model),
system: built.system,
user,
});
}
if (/^kimi/i.test(model)) {
return callBailian({
model: mapKimiBailianId(model),
system: built.system,
user,
});
}
// DeepSeek direct (api.deepseek.com, OpenAI-compat). Latest flagship
// is `deepseek-v4-pro`; `deepseek-v4-flash` is the lighter variant.
// The pre-2026-07-24 ids `deepseek-chat` / `deepseek-reasoner` are
// deprecated upstream but routed here too so older corpus runs stay
// reproducible until cutoff.
if (/^deepseek/i.test(model)) {
return callDeepSeek({
model: mapDeepSeekId(model),
system: built.system,
user,
});
}
throw new Error(
`No live adapter for model "${model}". Supported: minimax-* (MINIMAX_API_KEY), gpt-*/o1/o3/o4 (Codex CLI), glm-5.1 / kimi-k2.6 (ARK_CODING_KEY), glm-5 / kimi-k2.5 (DASHSCOPE_BAILIAN_CODING_KEY), deepseek-* (DEEPSEEK_API_KEY).`,
);
}
function mapMinimaxId(id: string): string {
const lower = id.toLowerCase();
if (lower.includes('m2.7') || lower === 'minimax-m2' || lower === 'minimax-m2.7') {
return 'MiniMax-M2.7';
}
return id;
}
function mapGlmOfficialId(id: string): string {
// GLM official CP (open.bigmodel.cn) used to ship "glm-4.7" as the
// current coding model. The harness no longer routes `glm-5.1` here
// (Ark took over, see mapGlmArkId) but this mapper is kept so
// callers who manually import `./clients/glm.ts` for A/B comparison
// can still get the canonical id.
const lower = id.toLowerCase();
if (lower === 'glm-5.1' || lower === 'glm-5.1-coding') return 'glm-4.7';
return id;
}
function mapGlmArkId(id: string): string {
// 方舟 CP exposes Zhipu GLM-5.1 on the coding-plan endpoint. The
// on-Ark model name is "glm-5.1" verbatim (no endpoint-ID alias
// required as of 2026-04-22 — earlier Ark models needed `ep-xxx`
// but GLM-5.1 was onboarded with the promoted short name).
const lower = id.toLowerCase();
if (lower === 'glm-5.1' || lower === 'glm-5.1-coding' || lower === 'glm-5.1-ark') {
return 'glm-5.1';
}
return id;
}
function mapKimiArkId(id: string): string {
// 方舟 CP exposes Moonshot Kimi K2.6 on the coding-plan endpoint.
// Volcengine accepts the promoted short name `kimi-k2.6` directly.
// Anything that matched the Ark router regex (kimi-k?2.6[-ark]?)
// normalizes to the canonical on-Ark id.
const lower = id.toLowerCase();
if (
lower === 'kimi-k2.6' ||
lower === 'kimi-2.6' ||
lower === 'kimi-k2.6-ark' ||
lower === 'kimi-2.6-ark'
) {
return 'kimi-k2.6';
}
return id;
}
function mapGlmBailianId(id: string): string {
return mapGlmBailianIdInternal(id);
}
function mapKimiBailianId(id: string): string {
// Bailian CP exposes Moonshot Kimi as `kimi-k2.5` — the `k` prefix
// matters; plain `kimi-2.5` gets HTTP 400 "model not supported".
// Source: Alibaba Cloud Model Studio Coding Plan FAQ (Apr 2026).
const lower = id.toLowerCase();
if (lower === 'kimi-2.5' || lower === 'kimi-k2.5' || lower === 'kimi') return 'kimi-k2.5';
return id;
}
function mapGlmBailianIdInternal(id: string): string {
// Bailian CP exposes Zhipu GLM as `glm-4.7` (current coding-plan
// preset id). Harness id `glm-5` is accepted as an alias by the
// server and produced responses in smoke tests, but standardize on
// the documented id to avoid silent deprecation.
const lower = id.toLowerCase();
if (lower === 'glm-5' || lower === 'glm-4.7' || lower === 'glm-coding') return 'glm-4.7';
return id;
}
function mapDeepSeekId(id: string): string {
// DeepSeek's current flagship is `deepseek-v4-pro`. Harness aliases
// `deepseek` (bare) and `deepseek-pro` resolve to that. The lighter
// `deepseek-v4-flash` and the deprecated `deepseek-chat` /
// `deepseek-reasoner` ids pass through verbatim — server still
// accepts them until 2026-07-24 sunset per docs.
const lower = id.toLowerCase();
if (lower === 'deepseek' || lower === 'deepseek-pro' || lower === 'deepseek-v4-pro') {
return 'deepseek-v4-pro';
}
return id;
}