codeburn/src/model-efficiency.ts
ozymandiashh 2fc4d32e66
Some checks are pending
CI / semgrep (push) Waiting to run
fix(devin): report friendly GPT model names with effort tier (#585)
* fix(devin): report friendly GPT model names with effort tier (#487)

Devin transcripts label each step with a generation_model in dash form with
an effort suffix (gpt-5-3-codex-xhigh). getShortModelName is keyed in dot form
(gpt-5.3-codex), and its version-boundary check matches the dash id against the
base gpt-5 entry, collapsing every Devin GPT variant to GPT-5.

Normalize the dash form to canonical dot form, map through the short-name table,
and surface the effort tier, e.g. GPT-5.3 Codex (xhigh). Fall back to the
friendly model_name when generation_model is an opaque MODEL_* id, and read
extra.generation_model so ATIF v1.7 transcripts behave like v1.4. Devin rows
in the JSON report and model-efficiency now key on the friendly name.

* fix(devin): stop dated GPT snapshots being mislabeled as versions

The friendly-name path treated any two-number dash id (gpt-4-1106-preview) as a
dotted minor version, producing corrupt labels like 'GPT-4.1106 Preview'.

- Restrict the dash-to-dot rewrite to a single-digit minor at a token boundary,
  matching Devin's real ids (gpt-5-3-codex) while leaving dated snapshots alone.
- In getFriendlyGptName, defer to getShortModelName when any suffix token is
  purely numeric, so unknown snapshot ids pass through raw instead of being
  fabricated into a fake friendly name.

Adds a regression case (gpt-4-1106-preview) to the Devin variant matrix.

* chore: remove accidental node_modules symlink

An absolute-path node_modules symlink was committed by mistake. It leaked a
local username, was a dangling symlink for everyone else, and broke git/npm on
checkout (git won't replace a real node_modules/ with the symlink). It slipped
past .gitignore because the ignore rule is 'node_modules/' with a trailing
slash, which matches a directory but not a symlink.
2026-07-01 16:01:49 +02:00

64 lines
2.1 KiB
TypeScript

import { getShortModelName } from './models.js'
import type { ParsedApiCall, ProjectSummary } from './types.js'
export type ModelEfficiency = {
model: string
editTurns: number
oneShotTurns: number
retries: number
editCostUSD: number
oneShotRate: number | null
retriesPerEdit: number | null
costPerEditUSD: number | null
}
type MutableModelEfficiency = Omit<ModelEfficiency, 'oneShotRate' | 'retriesPerEdit' | 'costPerEditUSD'>
function rate(num: number, den: number): number | null {
if (den === 0) return null
return Math.round((num / den) * 1000) / 10
}
function modelKey(call: ParsedApiCall): string {
return call.provider === 'devin' ? call.model : getShortModelName(call.model)
}
export function aggregateModelEfficiency(projects: ProjectSummary[]): Map<string, ModelEfficiency> {
const byModel = new Map<string, MutableModelEfficiency>()
function ensure(model: string): MutableModelEfficiency {
let stats = byModel.get(model)
if (!stats) {
stats = { model, editTurns: 0, oneShotTurns: 0, retries: 0, editCostUSD: 0 }
byModel.set(model, stats)
}
return stats
}
for (const project of projects) {
for (const session of project.sessions) {
for (const turn of session.turns) {
if (!turn.hasEdits || turn.assistantCalls.length === 0) continue
const primaryCall = turn.assistantCalls.find(c => modelKey(c) !== '<synthetic>')
if (!primaryCall) continue
const primaryModel = modelKey(primaryCall)
const stats = ensure(primaryModel)
stats.editTurns++
if (turn.retries === 0) stats.oneShotTurns++
stats.retries += turn.retries
stats.editCostUSD += turn.assistantCalls.reduce((sum, call) => {
return modelKey(call) === '<synthetic>' ? sum : sum + call.costUSD
}, 0)
}
}
}
return new Map([...byModel.entries()].map(([model, stats]) => [model, {
...stats,
oneShotRate: rate(stats.oneShotTurns, stats.editTurns),
retriesPerEdit: stats.editTurns > 0 ? Math.round((stats.retries / stats.editTurns) * 10) / 10 : null,
costPerEditUSD: stats.editTurns > 0 ? stats.editCostUSD / stats.editTurns : null,
}]))
}