ravensight-playtest 0.1.0
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 +380 -0
- package/addons/ravensight_driver/driver.gd +836 -0
- package/addons/ravensight_driver/export_plugin.gd +51 -0
- package/addons/ravensight_driver/plugin.cfg +7 -0
- package/addons/ravensight_driver/plugin.gd +36 -0
- package/bin/ravensight-playtest.js +31 -0
- package/package.json +45 -0
- package/src/api/README.md +500 -0
- package/src/api/client.js +340 -0
- package/src/api/errors.js +115 -0
- package/src/api/http.js +194 -0
- package/src/api/index.js +107 -0
- package/src/auth/deviceCode.js +79 -0
- package/src/auth/keychain.js +159 -0
- package/src/auth/session.js +128 -0
- package/src/cli.js +335 -0
- package/src/commands/brief.js +303 -0
- package/src/commands/check.js +318 -0
- package/src/commands/fakeCore.js +379 -0
- package/src/commands/init.js +120 -0
- package/src/commands/login.js +90 -0
- package/src/commands/logout.js +70 -0
- package/src/commands/open.js +125 -0
- package/src/commands/profile.js +262 -0
- package/src/commands/resume.js +156 -0
- package/src/commands/run.js +1015 -0
- package/src/commands/upload.js +137 -0
- package/src/config.js +100 -0
- package/src/dashboard.js +97 -0
- package/src/detect.js +77 -0
- package/src/errors.js +44 -0
- package/src/fsutil.js +77 -0
- package/src/godot.js +85 -0
- package/src/packs/index.js +191 -0
- package/src/paths.js +129 -0
- package/src/run/aggregate.js +658 -0
- package/src/run/args.js +111 -0
- package/src/run/context.js +181 -0
- package/src/run/deps.js +184 -0
- package/src/run/drivers/driver.js +183 -0
- package/src/run/drivers/godot-observation.js +138 -0
- package/src/run/drivers/godot-project.js +475 -0
- package/src/run/drivers/godot-rpc.js +225 -0
- package/src/run/drivers/godot.js +587 -0
- package/src/run/drivers/index.js +52 -0
- package/src/run/drivers/web.js +385 -0
- package/src/run/exit.js +21 -0
- package/src/run/heartbeat.js +131 -0
- package/src/run/index.js +31 -0
- package/src/run/json.js +56 -0
- package/src/run/model.js +384 -0
- package/src/run/paths.js +88 -0
- package/src/run/personaLoop.js +871 -0
- package/src/run/profile.js +214 -0
- package/src/run/regenerate.js +149 -0
- package/src/run/repoTools.js +286 -0
- package/src/run/report.js +222 -0
- package/src/run/resume.js +272 -0
- package/src/run/secretScan.js +171 -0
- package/src/run/state.js +198 -0
- package/src/run/synthetic.js +206 -0
- package/src/run/tools.js +344 -0
- package/src/run/transcript.js +93 -0
- package/src/run/usage.js +115 -0
- package/src/state/index.js +105 -0
- package/src/states.js +104 -0
- package/src/ui/index.js +195 -0
- package/src/upload/allowlist.js +116 -0
- package/src/upload/index.js +467 -0
- package/src/upload/queue.js +114 -0
- package/src/version.js +63 -0
package/src/run/model.js
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Every model call this CLI makes goes through here, and every one of them
|
|
5
|
+
* goes to Ravensight's proxy rather than to the Anthropic API.
|
|
6
|
+
*
|
|
7
|
+
* What that means concretely, and why:
|
|
8
|
+
*
|
|
9
|
+
* - **No Anthropic key exists on the developer's machine.** The client is
|
|
10
|
+
* built with `authToken` (the `gt_cli_...` token from the device-code
|
|
11
|
+
* flow) and an explicitly null `apiKey`, so a stray `ANTHROPIC_API_KEY`
|
|
12
|
+
* in the developer's environment is never picked up and never sent
|
|
13
|
+
* anywhere.
|
|
14
|
+
* - **No `model` field, ever.** Routing is the server's decision
|
|
15
|
+
* (`src/playtest/packs/routing.json`, server-only). The proxy rebuilds
|
|
16
|
+
* the body from an allowlist and supplies the model itself; a body that
|
|
17
|
+
* names a real routed model id is refused 403 `model_not_allowed`. The
|
|
18
|
+
* CLI cannot know which model a step resolves to, which is the point.
|
|
19
|
+
* - **Client tools only.** The proxy refuses `mcp_servers`, `container`,
|
|
20
|
+
* `betas` and `service_tier` outright, refuses any tool whose type starts
|
|
21
|
+
* `web_search`, `computer`, `bash` or `text_editor`, and refuses any tool
|
|
22
|
+
* whose name is not in the step's own `allowed_client_tools`. So the tool
|
|
23
|
+
* names in ./tools.js are not a style choice, they are the contract.
|
|
24
|
+
* - **Streaming is forced on by the proxy.** Asking for a non-stream
|
|
25
|
+
* response would get a stream anyway, so we ask for one.
|
|
26
|
+
* - **`X-Playtest-Job`, `-Run` and `-Step` are required on every call.**
|
|
27
|
+
* The step has to belong to the run's own module (plus the shared
|
|
28
|
+
* `brief.validate`), or the answer is 400 `step_not_allowed`.
|
|
29
|
+
*
|
|
30
|
+
* The two answers that change what the caller does next:
|
|
31
|
+
*
|
|
32
|
+
* - `X-Playtest-Budget: warn` comes back at or past 80 percent of the run's
|
|
33
|
+
* model budget. It is surfaced to the developer and it tells the persona
|
|
34
|
+
* loop to wrap up and write its report while there is still money to do
|
|
35
|
+
* it with.
|
|
36
|
+
* - `402 run_budget_exceeded` is the end of the run's spending. Partial
|
|
37
|
+
* results are written and uploaded, and the process exits 2.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** Where the proxy lives under the API host. */
|
|
41
|
+
export const MODEL_BASE_PATH = '/api/v1/playtest/model';
|
|
42
|
+
|
|
43
|
+
/** Step names, from routing.json by way of `clientRouting()`. */
|
|
44
|
+
export const STEPS = Object.freeze({
|
|
45
|
+
personaAct: 'persona_playtest.act',
|
|
46
|
+
personaReport: 'persona_playtest.report',
|
|
47
|
+
profileTriage: 'game_profile.triage',
|
|
48
|
+
profileSynthesis: 'game_profile.synthesis',
|
|
49
|
+
aggregateDedup: 'aggregate.dedup',
|
|
50
|
+
aggregateSynthesis: 'aggregate.synthesis',
|
|
51
|
+
briefValidate: 'brief.validate'
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/** Default retry budget for the answers that are worth retrying. */
|
|
55
|
+
export const MAX_ATTEMPTS = 4;
|
|
56
|
+
|
|
57
|
+
/** Codes the proxy raises that a retry can actually clear. */
|
|
58
|
+
const RETRYABLE_CODES = new Set(['concurrency_limited', 'velocity_limited', 'rate_limited', 'upstream_error']);
|
|
59
|
+
|
|
60
|
+
export class ProxyError extends Error {
|
|
61
|
+
/**
|
|
62
|
+
* @param {{status: number, code: string, message: string, retryAfter?: number|null, figures?: Object}} init
|
|
63
|
+
*/
|
|
64
|
+
constructor({ status, code, message, retryAfter = null, figures = {} }) {
|
|
65
|
+
super(message);
|
|
66
|
+
this.name = 'ProxyError';
|
|
67
|
+
this.status = status;
|
|
68
|
+
this.code = code;
|
|
69
|
+
this.retryAfter = retryAfter;
|
|
70
|
+
this.figures = figures;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The run has no model budget left. Write what we have and exit 2. */
|
|
74
|
+
get isBudget() {
|
|
75
|
+
return this.status === 402;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The job or run is closed, cancelled or past its wall clock. Exit 10. */
|
|
79
|
+
get isClosed() {
|
|
80
|
+
return this.status === 409;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Bad or missing credentials. Exit 4. */
|
|
84
|
+
get isAuth() {
|
|
85
|
+
return this.status === 401 || this.status === 403;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
get isRetryable() {
|
|
89
|
+
if (this.status === 502) return true;
|
|
90
|
+
return this.status === 429 && RETRYABLE_CODES.has(this.code);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Every retry was spent and the proxy still will not answer. Exit 5. */
|
|
95
|
+
export class ModelUnavailableError extends Error {
|
|
96
|
+
constructor(message, cause) {
|
|
97
|
+
super(message);
|
|
98
|
+
this.name = 'ModelUnavailableError';
|
|
99
|
+
this.cause = cause;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Read a header off whatever the SDK handed back: a `Headers` object on a
|
|
105
|
+
* response, a plain object on some error paths, or nothing at all.
|
|
106
|
+
*
|
|
107
|
+
* @param {any} headers
|
|
108
|
+
* @param {string} name
|
|
109
|
+
* @returns {string|null}
|
|
110
|
+
*/
|
|
111
|
+
export function headerValue(headers, name) {
|
|
112
|
+
if (!headers) return null;
|
|
113
|
+
if (typeof headers.get === 'function') return headers.get(name);
|
|
114
|
+
const lower = name.toLowerCase();
|
|
115
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
116
|
+
if (String(key).toLowerCase() === lower) return String(value);
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Normalise the two error envelopes the playtest surface uses.
|
|
123
|
+
*
|
|
124
|
+
* The router's own refusals are the Anthropic envelope the SDK already
|
|
125
|
+
* parses, `{ error: { type, message, ...figures } }`. The two middleware
|
|
126
|
+
* refusals (no `gt_cli_` credential, an ingest key) keep the repo's flat
|
|
127
|
+
* `{ error: 'code', message }` shape, because they come from the shared CLI
|
|
128
|
+
* auth chain. Both have to be understood here or a 401 reads as an
|
|
129
|
+
* unknown failure.
|
|
130
|
+
*
|
|
131
|
+
* @param {any} error an error thrown by the SDK
|
|
132
|
+
* @returns {ProxyError}
|
|
133
|
+
*/
|
|
134
|
+
export function toProxyError(error) {
|
|
135
|
+
const status = Number(error && error.status) || 0;
|
|
136
|
+
const body = (error && error.error) || {};
|
|
137
|
+
const inner = body && typeof body.error === 'object' && body.error !== null ? body.error : null;
|
|
138
|
+
const flat = typeof body.error === 'string' ? body.error : null;
|
|
139
|
+
const code = (inner && inner.type) || flat || (error && error.name) || 'model_error';
|
|
140
|
+
const message = (inner && inner.message) || body.message || (error && error.message) || 'The model proxy refused the call.';
|
|
141
|
+
const headerRetry = Number(headerValue(error && error.headers, 'retry-after'));
|
|
142
|
+
const bodyRetry = Number(inner && inner.retry_after);
|
|
143
|
+
const retryAfter = Number.isFinite(bodyRetry) && bodyRetry > 0
|
|
144
|
+
? bodyRetry
|
|
145
|
+
: (Number.isFinite(headerRetry) && headerRetry > 0 ? headerRetry : null);
|
|
146
|
+
const figures = {};
|
|
147
|
+
if (inner) {
|
|
148
|
+
for (const [key, value] of Object.entries(inner)) {
|
|
149
|
+
if (key !== 'type' && key !== 'message') figures[key] = value;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return new ProxyError({ status, code, message, retryAfter, figures });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @param {Object} opts
|
|
157
|
+
* @param {string} opts.apiUrl e.g. https://api.ravensight.io
|
|
158
|
+
* @param {string} opts.token the gt_cli_ token
|
|
159
|
+
* @param {number} [opts.timeoutMs]
|
|
160
|
+
* @param {Function} [opts.fetch] test seam
|
|
161
|
+
* @returns {Anthropic}
|
|
162
|
+
*/
|
|
163
|
+
export function createModelClient({ apiUrl, token, timeoutMs = 300000, fetch: fetchImpl } = {}) {
|
|
164
|
+
if (!apiUrl) throw new Error('createModelClient needs an apiUrl');
|
|
165
|
+
if (!token) throw new Error('createModelClient needs a gt_cli_ token');
|
|
166
|
+
return new Anthropic({
|
|
167
|
+
baseURL: `${String(apiUrl).replace(/\/+$/, '')}${MODEL_BASE_PATH}`,
|
|
168
|
+
authToken: token,
|
|
169
|
+
// Explicitly null so the SDK never falls back to a local
|
|
170
|
+
// ANTHROPIC_API_KEY. This CLI has no business holding one.
|
|
171
|
+
apiKey: null,
|
|
172
|
+
// Retries are ours: the proxy's 429s carry a code and a retry_after
|
|
173
|
+
// that decide whether waiting will help at all, and the SDK cannot see
|
|
174
|
+
// that difference.
|
|
175
|
+
maxRetries: 0,
|
|
176
|
+
timeout: timeoutMs,
|
|
177
|
+
...(fetchImpl ? { fetch: fetchImpl } : {})
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Merge one usage payload into an accumulator. `message_delta.usage` is
|
|
183
|
+
* cumulative rather than incremental, so the last value of each field wins
|
|
184
|
+
* instead of being added (the proxy does the same thing on its side, and
|
|
185
|
+
* the two figures have to agree).
|
|
186
|
+
*
|
|
187
|
+
* @param {Object} accumulator
|
|
188
|
+
* @param {Object|null|undefined} usage
|
|
189
|
+
*/
|
|
190
|
+
export function mergeUsage(accumulator, usage) {
|
|
191
|
+
if (!usage || typeof usage !== 'object') return accumulator;
|
|
192
|
+
for (const field of ['input_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens', 'output_tokens']) {
|
|
193
|
+
const value = Number(usage[field]);
|
|
194
|
+
if (Number.isFinite(value)) accumulator[field] = value;
|
|
195
|
+
}
|
|
196
|
+
return accumulator;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* One model turn.
|
|
201
|
+
*
|
|
202
|
+
* @param {Object} args
|
|
203
|
+
* @param {Anthropic} args.client
|
|
204
|
+
* @param {string} args.jobId
|
|
205
|
+
* @param {string} args.runId
|
|
206
|
+
* @param {string} args.step a routing.json step name
|
|
207
|
+
* @param {Array} args.messages
|
|
208
|
+
* @param {string|Array} [args.system]
|
|
209
|
+
* @param {Array} [args.tools]
|
|
210
|
+
* @param {number} args.maxTokens asked for, clamped by the step's own ceiling
|
|
211
|
+
* @param {boolean} [args.escalate] honoured only on an escalatable step
|
|
212
|
+
* @param {AbortSignal} [args.signal]
|
|
213
|
+
* @returns {Promise<{text: string, toolUses: Array, stopReason: string|null, usage: Object, model: string|null, tier: string|null, budget: string|null}>}
|
|
214
|
+
*/
|
|
215
|
+
export async function callStep({
|
|
216
|
+
client,
|
|
217
|
+
jobId,
|
|
218
|
+
runId,
|
|
219
|
+
step,
|
|
220
|
+
messages,
|
|
221
|
+
system,
|
|
222
|
+
tools,
|
|
223
|
+
maxTokens,
|
|
224
|
+
escalate = false,
|
|
225
|
+
signal
|
|
226
|
+
}) {
|
|
227
|
+
const headers = {
|
|
228
|
+
'X-Playtest-Job': jobId,
|
|
229
|
+
'X-Playtest-Run': runId,
|
|
230
|
+
'X-Playtest-Step': step
|
|
231
|
+
};
|
|
232
|
+
if (escalate) headers['X-Playtest-Escalate'] = '1';
|
|
233
|
+
|
|
234
|
+
const body = { messages, max_tokens: maxTokens, stream: true };
|
|
235
|
+
if (system !== undefined) body.system = system;
|
|
236
|
+
if (Array.isArray(tools) && tools.length > 0) body.tools = tools;
|
|
237
|
+
|
|
238
|
+
let stream;
|
|
239
|
+
let response;
|
|
240
|
+
try {
|
|
241
|
+
const answered = await client.messages.create(body, { headers, signal }).withResponse();
|
|
242
|
+
stream = answered.data;
|
|
243
|
+
response = answered.response;
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if (error && (error.name === 'AbortError' || error.name === 'APIUserAbortError')) throw error;
|
|
246
|
+
throw toProxyError(error);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const tier = headerValue(response && response.headers, 'x-playtest-model-tier');
|
|
250
|
+
const budget = headerValue(response && response.headers, 'x-playtest-budget');
|
|
251
|
+
|
|
252
|
+
const usage = { input_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, output_tokens: 0 };
|
|
253
|
+
const blocks = new Map();
|
|
254
|
+
let text = '';
|
|
255
|
+
let stopReason = null;
|
|
256
|
+
let model = null;
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
await consume(stream);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (error && (error.name === 'AbortError' || error.name === 'APIUserAbortError')) throw error;
|
|
262
|
+
// The SDK's own iterator raises an `event: error` frame as an APIError
|
|
263
|
+
// with no status: the status line was sent long ago. Everything from the
|
|
264
|
+
// first byte onwards is therefore a 502 by construction, which is also
|
|
265
|
+
// how the proxy classifies its own mid-stream failures.
|
|
266
|
+
const proxied = toProxyError(error);
|
|
267
|
+
throw proxied.status ? proxied : new ProxyError({
|
|
268
|
+
status: 502,
|
|
269
|
+
code: proxied.code === 'model_error' ? 'upstream_error' : proxied.code,
|
|
270
|
+
message: proxied.message
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function consume(events) {
|
|
275
|
+
for await (const event of events) {
|
|
276
|
+
switch (event.type) {
|
|
277
|
+
case 'message_start':
|
|
278
|
+
// The proxy re-emits the upstream events verbatim, so this is where
|
|
279
|
+
// the resolved model id becomes visible even though the tier header
|
|
280
|
+
// deliberately does not carry it. It is recorded in usage.json for
|
|
281
|
+
// reconciliation against the server's own figures, never used to
|
|
282
|
+
// pick a model.
|
|
283
|
+
model = (event.message && event.message.model) || model;
|
|
284
|
+
mergeUsage(usage, event.message && event.message.usage);
|
|
285
|
+
break;
|
|
286
|
+
case 'content_block_start':
|
|
287
|
+
blocks.set(event.index, { block: event.content_block, json: '' });
|
|
288
|
+
break;
|
|
289
|
+
case 'content_block_delta': {
|
|
290
|
+
const entry = blocks.get(event.index);
|
|
291
|
+
if (!entry) break;
|
|
292
|
+
if (event.delta && event.delta.type === 'text_delta') {
|
|
293
|
+
text += event.delta.text;
|
|
294
|
+
} else if (event.delta && event.delta.type === 'input_json_delta') {
|
|
295
|
+
entry.json += event.delta.partial_json || '';
|
|
296
|
+
}
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
case 'message_delta':
|
|
300
|
+
if (event.delta && event.delta.stop_reason) stopReason = event.delta.stop_reason;
|
|
301
|
+
mergeUsage(usage, event.usage);
|
|
302
|
+
break;
|
|
303
|
+
default:
|
|
304
|
+
// Anything else, `ping` included, is not a frame this loop has an
|
|
305
|
+
// opinion about. The SDK drops `ping` before it reaches here and
|
|
306
|
+
// raises an `error` frame as a thrown APIError, which the caller
|
|
307
|
+
// above converts.
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const toolUses = [];
|
|
314
|
+
for (const { block, json } of blocks.values()) {
|
|
315
|
+
if (!block || block.type !== 'tool_use') continue;
|
|
316
|
+
let input = block.input && Object.keys(block.input).length > 0 ? block.input : {};
|
|
317
|
+
if (json) {
|
|
318
|
+
try {
|
|
319
|
+
input = JSON.parse(json);
|
|
320
|
+
} catch {
|
|
321
|
+
// A truncated tool call (usually `max_tokens` cutting the JSON in
|
|
322
|
+
// half) is reported as a malformed call so the caller can tell the
|
|
323
|
+
// model rather than crash on it.
|
|
324
|
+
toolUses.push({ id: block.id, name: block.name, input: null, malformed: true, raw: json });
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
toolUses.push({ id: block.id, name: block.name, input, malformed: false });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return { text, toolUses, stopReason, usage, model, tier, budget };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* @param {number} ms
|
|
336
|
+
* @param {Function} [sleep]
|
|
337
|
+
*/
|
|
338
|
+
function wait(ms, sleep) {
|
|
339
|
+
if (sleep) return sleep(ms);
|
|
340
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* `callStep` with the retries that are worth taking, and none of the ones
|
|
345
|
+
* that are not.
|
|
346
|
+
*
|
|
347
|
+
* Retried: 502 `upstream_error`, and a 429 whose code says the wait is the
|
|
348
|
+
* fix (`concurrency_limited`, `velocity_limited`, `rate_limited`). The
|
|
349
|
+
* proxy's own `retry_after` is honoured when it sends one, since it knows
|
|
350
|
+
* how long its hourly window or its upstream asked for.
|
|
351
|
+
*
|
|
352
|
+
* Never retried: 402 (no money left, waiting changes nothing), 409 (the job
|
|
353
|
+
* is closed), 401 and 403 (credentials or scope), 400 and 404 (a contract
|
|
354
|
+
* problem in this CLI, and hammering it just spends the developer's
|
|
355
|
+
* patience). 429 `count_tokens_limited` is not retried either: the job's
|
|
356
|
+
* free-count ceiling does not refill inside one job.
|
|
357
|
+
*
|
|
358
|
+
* @param {Object} args see callStep, plus:
|
|
359
|
+
* @param {number} [args.maxAttempts]
|
|
360
|
+
* @param {(info: Object) => void} [args.onRetry]
|
|
361
|
+
* @param {Function} [args.sleep] test seam
|
|
362
|
+
*/
|
|
363
|
+
export async function callStepWithRetry(args) {
|
|
364
|
+
const { maxAttempts = MAX_ATTEMPTS, onRetry, sleep, ...rest } = args;
|
|
365
|
+
let last = null;
|
|
366
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
367
|
+
try {
|
|
368
|
+
return await callStep(rest);
|
|
369
|
+
} catch (error) {
|
|
370
|
+
if (error instanceof ProxyError && !error.isRetryable) throw error;
|
|
371
|
+
if (error && (error.name === 'AbortError' || error.name === 'APIUserAbortError')) throw error;
|
|
372
|
+
last = error;
|
|
373
|
+
if (attempt === maxAttempts) break;
|
|
374
|
+
const suggested = error instanceof ProxyError && error.retryAfter ? error.retryAfter * 1000 : null;
|
|
375
|
+
const backoff = suggested ?? Math.min(1000 * 2 ** (attempt - 1), 30000);
|
|
376
|
+
if (onRetry) onRetry({ attempt, maxAttempts, waitMs: backoff, error });
|
|
377
|
+
await wait(backoff, sleep);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const reason = last instanceof ProxyError ? `${last.status} ${last.code}: ${last.message}` : String(last && last.message);
|
|
381
|
+
throw new ModelUnavailableError(`The model proxy did not answer after ${maxAttempts} attempts (${reason}).`, last);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export default { createModelClient, callStep, callStepWithRetry, STEPS, ProxyError, ModelUnavailableError };
|
package/src/run/paths.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The local layout, spec 17 section 1.5:
|
|
5
|
+
*
|
|
6
|
+
* <repo>/.ravensight/
|
|
7
|
+
* config.json (cli-core: game id, driver defaults)
|
|
8
|
+
* jobs/<job_id>/state.json the resumable journal (./state.js)
|
|
9
|
+
* jobs/<job_id>/capability-report.json
|
|
10
|
+
* jobs/<job_id>/aggregate-report.md
|
|
11
|
+
* jobs/<job_id>/aggregate-report.json
|
|
12
|
+
* jobs/<job_id>/runs/<run_id>/report.md
|
|
13
|
+
* jobs/<job_id>/runs/<run_id>/report.json
|
|
14
|
+
* jobs/<job_id>/runs/<run_id>/usage.json
|
|
15
|
+
* jobs/<job_id>/runs/<run_id>/transcript.jsonl
|
|
16
|
+
* jobs/<job_id>/runs/<run_id>/screenshots/NN-slug.png
|
|
17
|
+
* jobs/<job_id>/runs/<run_id>/session.webm
|
|
18
|
+
*
|
|
19
|
+
* The basenames are load-bearing: the upload presign keys are derived from
|
|
20
|
+
* the path relative to the run directory, and the server's own allowlist
|
|
21
|
+
* and lifecycle rules (the S3 lifecycle expires every `session.webm` and
|
|
22
|
+
* `transcript.jsonl` at 90 days) are written against exactly these names.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export const RAVENSIGHT_DIR = '.ravensight';
|
|
26
|
+
|
|
27
|
+
/** @param {string} repoRoot */
|
|
28
|
+
export function ravensightDir(repoRoot) {
|
|
29
|
+
return path.join(repoRoot, RAVENSIGHT_DIR);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @param {string} repoRoot */
|
|
33
|
+
export function jobsDir(repoRoot) {
|
|
34
|
+
return path.join(ravensightDir(repoRoot), 'jobs');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} repoRoot
|
|
39
|
+
* @param {string} jobId
|
|
40
|
+
*/
|
|
41
|
+
export function jobDir(repoRoot, jobId) {
|
|
42
|
+
return path.join(jobsDir(repoRoot), jobId);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {string} repoRoot
|
|
47
|
+
* @param {string} jobId
|
|
48
|
+
* @param {string} runId
|
|
49
|
+
*/
|
|
50
|
+
export function runDir(repoRoot, jobId, runId) {
|
|
51
|
+
return path.join(jobDir(repoRoot, jobId), 'runs', runId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @param {string} dir a run directory */
|
|
55
|
+
export function screenshotsDir(dir) {
|
|
56
|
+
return path.join(dir, 'screenshots');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The file names inside a run directory, in upload order: reports first,
|
|
61
|
+
* then screenshots, then the opt-in transcript and video (spec 17 section
|
|
62
|
+
* 1.7).
|
|
63
|
+
*/
|
|
64
|
+
export const RUN_FILES = Object.freeze({
|
|
65
|
+
reportMd: 'report.md',
|
|
66
|
+
reportJson: 'report.json',
|
|
67
|
+
usageJson: 'usage.json',
|
|
68
|
+
transcript: 'transcript.jsonl',
|
|
69
|
+
video: 'session.webm'
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export const JOB_FILES = Object.freeze({
|
|
73
|
+
state: 'state.json',
|
|
74
|
+
capabilityReport: 'capability-report.json',
|
|
75
|
+
aggregateMd: 'aggregate-report.md',
|
|
76
|
+
aggregateJson: 'aggregate-report.json'
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
export default {
|
|
80
|
+
RAVENSIGHT_DIR,
|
|
81
|
+
RUN_FILES,
|
|
82
|
+
JOB_FILES,
|
|
83
|
+
ravensightDir,
|
|
84
|
+
jobsDir,
|
|
85
|
+
jobDir,
|
|
86
|
+
runDir,
|
|
87
|
+
screenshotsDir
|
|
88
|
+
};
|