mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 06:54:50 +01:00
fix(autoplan): a truncated api call settles its cost and a retry is admitted afresh against the cap
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
f5e603c75e
commit
da4fd1975b
3 files changed
+52
-5
No files matched your search
+8
-4
@@ -44,7 +44,7 @@ import { exportPublic } from '../lib/autoplan-public';
|
||||
import { migrateRun } from '../lib/autoplan-migrate';
|
||||
import { reviseRun } from '../lib/autoplan-revise';
|
||||
import { pendingAmendments, type RunState, type Voice } from '../lib/autoplan-state';
|
||||
import { FILES, RunError, currentPhase, initRun, markOutside, nextStep, readState, resumeRun, statusLines, submitResult, withRunLock, writeState, type NextOptions } from '../lib/autoplan-run';
|
||||
import { FILES, RunError, currentPhase, fitReservation, initRun, markOutside, nextStep, readState, resumeRun, retireAttempt, statusLines, submitResult, withRunLock, writeState, type NextOptions } from '../lib/autoplan-run';
|
||||
import { driverFile, type Phase } from '../lib/autoplan-prompts';
|
||||
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
|
||||
import { createRunner, familyConflict, type RunnerId } from '../lib/outside-voice-runner';
|
||||
@@ -321,6 +321,8 @@ async function doRun(out: string, rest: string[], json: boolean): Promise<number
|
||||
const row = isApi(voice) ? pricingRow(modelOf(voice)) : undefined;
|
||||
return row ? +((fs.statSync(promptFile).size / 3 * row.input_per_mtok + (maxTokens ?? DEFAULT_MAX_TOKENS) * row.output_per_mtok) / 1e6).toFixed(6) : undefined;
|
||||
};
|
||||
const usdOf = (voice: Voice, used: { input_tokens?: number; output_tokens?: number } | undefined) =>
|
||||
isApi(voice) && pricingRow(modelOf(voice)) && typeof used?.input_tokens === 'number' && typeof used?.output_tokens === 'number' ? estimateCostUsd({ input: used.input_tokens, output: used.output_tokens }, modelOf(voice)) : undefined;
|
||||
for (let guard = 0; guard < 40; guard++) {
|
||||
const code = withRunLock(out, () => doNext(out, rest, true, price));
|
||||
if (code !== EXIT.ok) return code;
|
||||
@@ -335,6 +337,8 @@ async function doRun(out: string, rest: string[], json: boolean): Promise<number
|
||||
const open = v.attempts?.find(a => a.state === 'dispatched');
|
||||
if (!open) continue;
|
||||
const runnerId = voice === 'native' ? nativeRunner : outsideRunner;
|
||||
const cost = price(voice, open.prompt);
|
||||
if (cost !== undefined) withRunLock(out, () => fitReservation(out, phase as Phase, voice, open.attempt, cost));
|
||||
const runner = createRunner(runnerId);
|
||||
const remaining = deadlineAt ? deadlineAt - Date.now() : runnerId === 'api' ? apiTimeoutMs(maxTokens) : 300_000;
|
||||
if (remaining <= 0) { withRunLock(out, () => { const s = readState(out); s.status = 'interrupted'; writeState(s); }); process.stdout.write(resultLineFor('interrupted', out) + '\n'); return EXIT.fail; }
|
||||
@@ -346,12 +350,12 @@ async function doRun(out: string, rest: string[], json: boolean): Promise<number
|
||||
if (res.status !== 'completed' && res.status !== 'unverified') {
|
||||
if (voice === 'outside') { withRunLock(out, () => markOutside(out, phase as Phase, 'unavailable', `${res.runner} ${res.status}${res.detail ? `: ${res.detail}` : ''}`)); continue; }
|
||||
process.stderr.write(`gstack-autoplan run: native voice ${res.status}${res.reason ? `: ${res.reason}` : ''}${res.detail ? `: ${res.detail}` : ''}\n`);
|
||||
withRunLock(out, () => { const s = readState(out); s.status = 'interrupted'; writeState(s); });
|
||||
withRunLock(out, () => { retireAttempt(out, phase as Phase, voice, open.attempt, usdOf(voice, res.usage) ?? 'unknown'); const s = readState(out); s.status = 'interrupted'; writeState(s); });
|
||||
process.stdout.write(resultLineFor('interrupted', out) + '\n');
|
||||
return EXIT.fail;
|
||||
}
|
||||
const used = res.usage;
|
||||
const usage = isApi(voice) && pricingRow(modelOf(voice)) && typeof used.input_tokens === 'number' && typeof used.output_tokens === 'number' ? ['--usage-usd', String(estimateCostUsd({ input: used.input_tokens, output: used.output_tokens }, modelOf(voice)))] : [];
|
||||
const usd = usdOf(voice, res.usage);
|
||||
const usage = usd === undefined ? [] : ['--usage-usd', String(usd)];
|
||||
try { withRunLock(out, () => doSubmit(out, ['--phase', phase, '--voice', voice, '--result', `${open.result}.raw`, '--model', res.model ?? modelOf(voice), '--attempt', open.attempt, '--runner', res.runner, ...usage], true)); }
|
||||
catch (e) {
|
||||
if (voice !== 'outside' || !(e instanceof RunError)) throw e;
|
||||
|
||||
+28
-1
@@ -31,7 +31,7 @@ import type { RunStatus } from './headless-artifacts';
|
||||
import { sha256File } from './headless-artifacts';
|
||||
import { outsideConsent } from './outside-consent';
|
||||
import { familyConflict, modelFamily } from './outside-voice-runner';
|
||||
import { renderSpend, reserve, settle, summarize, readLedger } from './spend-ledger';
|
||||
import { SpendCapExceeded, release, renderSpend, reserve, settle, summarize, readLedger } from './spend-ledger';
|
||||
import { DEFAULT_MAX_ROUNDS, attemptEstimate, overCap, resumeStop, roundCap, roundOf, roundsLine, stopForBudget, stopLines } from './autoplan-budget';
|
||||
import { annotateConfirm, confirmBlock } from './autoplan-confirm';
|
||||
|
||||
@@ -413,6 +413,33 @@ export function closeOutside(state: RunState, phase: Phase, status: 'unavailable
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Raise an open attempt's reservation to what the call about to run can cost; the cap still binds (SPEND_CAP_EXCEEDED). */
|
||||
export function fitReservation(out: string, phase: Phase, voice: Voice, attempt: string, usd: number): void {
|
||||
const state = readState(out);
|
||||
const a = voiceState(state, phase, voice).attempts?.find(x => x.attempt === attempt);
|
||||
if (!a || usd <= a.estimate_usd + 1e-9) return;
|
||||
const file = path.join(out, FILES.spend);
|
||||
release(file, attempt);
|
||||
try { reserve(file, attempt, usd); }
|
||||
catch (e) {
|
||||
reserve(file, attempt, a.estimate_usd);
|
||||
if (e instanceof SpendCapExceeded) throw new RunError('SPEND_CAP_EXCEEDED', `${attempt} (${phase} ${voice}) was admitted at ${a.estimate_usd}; the call it is about to make can cost ${usd}, past the cap ${state.spend_cap_usd}`, 3);
|
||||
throw e;
|
||||
}
|
||||
a.estimate_usd = usd;
|
||||
writeState(state);
|
||||
}
|
||||
|
||||
/** A paid call that failed (truncated, refused): settle what it cost and supersede its attempt, so a retry is admitted afresh. */
|
||||
export function retireAttempt(out: string, phase: Phase, voice: Voice, attempt: string, usd: number | 'unknown'): void {
|
||||
const state = readState(out);
|
||||
const a = voiceState(state, phase, voice).attempts?.find(x => x.attempt === attempt);
|
||||
if (a) { a.state = 'superseded'; a.terminal_at = now(); }
|
||||
settle(path.join(out, FILES.spend), attempt, usd);
|
||||
journal(out, { event: 'superseded', attempt, phase, voice, usage_usd: usd });
|
||||
writeState(state);
|
||||
}
|
||||
|
||||
/** Mark the outside voice of the current phase unavailable/failed (the runner could not run); closes the phase if native is bound. */
|
||||
export function markOutside(out: string, phase: Phase, status: 'unavailable' | 'failed', detail: string): RunState {
|
||||
const state = readState(out);
|
||||
|
||||
@@ -104,4 +104,20 @@ describe('gstack-autoplan run --native-runner api --runner api', () => {
|
||||
expect(r.stderr).toMatch(/native voice unavailable: truncated: .*output limit/);
|
||||
expect(r.state.status).toBe('interrupted');
|
||||
}, 150_000);
|
||||
|
||||
test('a truncated call settles what it cost, and a retry with a larger output limit is admitted afresh against the cap', async () => {
|
||||
mode = 'cut';
|
||||
const first = await run('cut-retry', ['--spend-cap', '8']);
|
||||
expect(first.state.status).toBe('interrupted');
|
||||
const truncated = first.state.phases.ceo.native.attempts[0].attempt;
|
||||
const ledger = JSON.parse(fs.readFileSync(path.join(first.out, 'spend.json'), 'utf8'));
|
||||
expect(ledger.reserved.map((r: any) => r.attempt)).not.toContain(truncated);
|
||||
expect(ledger.settled.find((s: any) => s.attempt === truncated).usd).toBeGreaterThan(0);
|
||||
mode = 'ok';
|
||||
calls = 0;
|
||||
const retry = await run('cut-retry', ['--spend-cap', '8', '--max-tokens', '2000000']);
|
||||
expect(retry.status).toBe(3);
|
||||
expect(retry.stdout + retry.stderr).toContain('SPEND_CAP_EXCEEDED');
|
||||
expect(calls).toBe(0);
|
||||
}, 150_000);
|
||||
});
|
||||
Reference in new issue
Block a user