@revyl/verify 0.3.1 → 0.4.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.
@@ -0,0 +1,215 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+ import { VerifyError, errorMessage, isVerifyError } from '../errors.js';
3
+ import { fitPng } from './image.js';
4
+ /** Runs instructions and validations on a model you choose, on your machine. Revyl only receives device actions. */
5
+ export function localAgent(options) {
6
+ if (!options || typeof options.model?.complete !== 'function' || typeof options.model.name !== 'string') {
7
+ throw new VerifyError('INVALID_ARGUMENT', 'localAgent() needs a model adapter, such as anthropic() or openaiCompatible().');
8
+ }
9
+ const grounding = options.grounding ?? 'revyl';
10
+ if (grounding !== 'model' && grounding !== 'revyl')
11
+ throw new VerifyError('INVALID_ARGUMENT', "localAgent() grounding must be 'model' or 'revyl'.");
12
+ const maxTurns = bounded(options.maxTurns ?? 20, 1, 100, 'maxTurns');
13
+ const modelTimeout = bounded(options.modelTimeout ?? 60_000, 1_000, 600_000, 'modelTimeout');
14
+ const maxImageSide = bounded(options.maxImageSide ?? 1280, 256, 4096, 'maxImageSide');
15
+ return Object.freeze({ model: options.model, grounding, maxTurns, modelTimeout, maxImageSide });
16
+ }
17
+ const VALIDATION_ATTEMPTS = 3;
18
+ const NO_TOOL_LIMIT = 3;
19
+ const ABORTING_CODES = new Set(['SESSION_ENDED', 'SESSION_BUSY', 'AUTH_REJECTED', 'EDGE_BLOCKED', 'MODEL_ERROR', 'TIMEOUT', 'API_ERROR', 'ACTION_UNCERTAIN']);
20
+ /** Runs one instruction or validation through the agent's model. The caller enforces the step deadline through signal. */
21
+ export async function runLocalStep(type, task, agent, controls, signal, log) {
22
+ const run = { id: agent.model.name, grounding: agent.grounding, turns: [], inputTokens: 0, outputTokens: 0 };
23
+ const history = [];
24
+ const size = await controls.screenSize(signal);
25
+ const tools = type === 'validation' ? [VERDICT_TOOL] : instructionTools(controls.platform, agent.grounding);
26
+ const turns = type === 'validation' ? VALIDATION_ATTEMPTS : agent.maxTurns;
27
+ let turnsWithoutTool = 0;
28
+ for (let turn = 1; turn <= turns; turn += 1) {
29
+ signal.throwIfAborted();
30
+ const shot = await controls.capture(signal);
31
+ const image = fitPng(shot.png, agent.maxImageSide);
32
+ const modelStarted = Date.now();
33
+ const reply = await agent.model.complete({
34
+ system: type === 'validation' ? validationPrompt(controls.platform) : instructionPrompt(controls.platform, agent.grounding),
35
+ text: turnText(type, task, image, history),
36
+ image,
37
+ tools,
38
+ }, AbortSignal.any([signal, AbortSignal.timeout(agent.modelTimeout)]));
39
+ const record = { action: 'no tool call', ok: false, modelMs: Date.now() - modelStarted, actionMs: 0, inputTokens: reply.usage.inputTokens, outputTokens: reply.usage.outputTokens };
40
+ run.turns.push(record);
41
+ run.inputTokens += record.inputTokens;
42
+ run.outputTokens += record.outputTokens;
43
+ const call = reply.toolCall;
44
+ if (reply.truncated) {
45
+ throw new VerifyError('MODEL_ERROR', `${agent.model.name}'s reply was cut off at its output token cap ${call ? 'in the middle of a tool call, so Verify did not use it' : 'before it called a tool'}. Pass the model to localAgent() through an adapter with a higher maxTokens, such as anthropic({ model, maxTokens: 8192 }) or openaiCompatible({ model, baseURL, maxTokens: 8192 }).`);
46
+ }
47
+ if (!call) {
48
+ turnsWithoutTool += 1;
49
+ if (turnsWithoutTool === NO_TOOL_LIMIT) {
50
+ throw new VerifyError('MODEL_ERROR', `${agent.model.name} answered ${NO_TOOL_LIMIT} times in a row without calling a tool. Pick a model that supports tool calls.`);
51
+ }
52
+ history.push(`${turn}. You answered without calling a tool. Call exactly one tool.`);
53
+ log(` · turn ${turn}: no tool call (${record.modelMs} ms)`);
54
+ continue;
55
+ }
56
+ turnsWithoutTool = 0;
57
+ if (!tools.some((tool) => tool.name === call.name)) {
58
+ record.action = call.name.slice(0, 40);
59
+ history.push(`${turn}. ${record.action}: failed: not one of the offered tools (${tools.map((tool) => tool.name).join(', ')}).`);
60
+ log(` · turn ${turn}: ${record.action} is not an offered tool (${record.modelMs} ms)`);
61
+ continue;
62
+ }
63
+ if (call.name === 'done' || call.name === 'verdict') {
64
+ const passed = (call.name === 'done' ? call.arguments.success : call.arguments.passed) === true;
65
+ const reason = typeof call.arguments.reason === 'string' ? call.arguments.reason.slice(0, 1_000) : undefined;
66
+ record.action = `${call.name} ${passed}`;
67
+ record.ok = true;
68
+ log(` · turn ${turn}: ${record.action} (${record.modelMs} ms)`);
69
+ return { passed, ...(reason ? { reason } : {}), run };
70
+ }
71
+ const actionStarted = Date.now();
72
+ try {
73
+ const action = toAction(call, controls.platform, agent.grounding, image, size);
74
+ record.action = describe(action);
75
+ const at = await controls.perform(action, shot.screenToken, signal);
76
+ record.ok = true;
77
+ history.push(`${turn}. ${record.action}${at ? ` at (${at.x}, ${at.y})` : ''}: done.`);
78
+ }
79
+ catch (error) {
80
+ if (isVerifyError(error) && ABORTING_CODES.has(error.code))
81
+ throw error;
82
+ if (record.action === 'no tool call')
83
+ record.action = call.name;
84
+ history.push(`${turn}. ${record.action}: failed: ${errorMessage(error).slice(0, 300)}`);
85
+ }
86
+ record.actionMs = Date.now() - actionStarted;
87
+ log(` · turn ${turn}: ${record.action}${record.ok ? '' : ' (failed)'} (model ${record.modelMs} ms, action ${record.actionMs} ms)`);
88
+ await delay(controls.platform === 'web' ? 500 : 1_000, undefined, { signal });
89
+ }
90
+ if (type === 'validation')
91
+ throw new VerifyError('STEP_FAILED', `${agent.model.name} gave no verdict in ${turns} attempts.`);
92
+ return { passed: false, reason: `${agent.model.name} did not finish within ${turns} turns.`, run };
93
+ }
94
+ function toAction(call, platform, grounding, image, size) {
95
+ const args = call.arguments;
96
+ const location = (required) => {
97
+ if (grounding === 'revyl') {
98
+ if (typeof args.target === 'string' && args.target.trim())
99
+ return { target: args.target.trim().slice(0, 500) };
100
+ if (required)
101
+ throw new VerifyError('INVALID_ARGUMENT', 'target is required: describe the element by its visible text or appearance.');
102
+ return undefined;
103
+ }
104
+ if (typeof args.x !== 'number' || typeof args.y !== 'number') {
105
+ if (required)
106
+ throw new VerifyError('INVALID_ARGUMENT', 'x and y are required.');
107
+ return undefined;
108
+ }
109
+ if (args.x < 0 || args.y < 0 || args.x >= image.width || args.y >= image.height) {
110
+ throw new VerifyError('INVALID_ARGUMENT', `(${args.x}, ${args.y}) is outside the ${image.width}x${image.height} screenshot.`);
111
+ }
112
+ return { x: Math.round((args.x * size.width) / image.width), y: Math.round((args.y * size.height) / image.height) };
113
+ };
114
+ switch (call.name) {
115
+ case 'tap':
116
+ return { kind: 'tap', at: location(true) };
117
+ case 'type':
118
+ if (typeof args.text !== 'string')
119
+ throw new VerifyError('INVALID_ARGUMENT', 'text is required.');
120
+ return { kind: 'type', at: location(true), text: args.text, clear: args.clear !== false };
121
+ case 'swipe': {
122
+ if (!['up', 'down', 'left', 'right'].includes(String(args.direction)))
123
+ throw new VerifyError('INVALID_ARGUMENT', 'direction must be up, down, left or right.');
124
+ const from = location(false);
125
+ return { kind: 'swipe', direction: args.direction, ...(from ? { from } : {}) };
126
+ }
127
+ case 'press_key':
128
+ if (args.key !== 'enter' && args.key !== 'backspace')
129
+ throw new VerifyError('INVALID_ARGUMENT', 'key must be enter or backspace.');
130
+ return { kind: 'key', key: args.key };
131
+ case 'back':
132
+ if (platform === 'ios')
133
+ throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', 'iOS has no back button; tap the on-screen back control.');
134
+ return { kind: 'back' };
135
+ case 'open_url':
136
+ if (typeof args.url !== 'string' || !args.url)
137
+ throw new VerifyError('INVALID_ARGUMENT', 'url is required.');
138
+ return { kind: 'open_url', url: args.url };
139
+ default:
140
+ throw new VerifyError('INVALID_ARGUMENT', `${call.name} is not a tool.`);
141
+ }
142
+ }
143
+ function describe(action) {
144
+ const where = (at) => (at === undefined ? '' : 'target' in at ? ` "${at.target}"` : ` (${at.x}, ${at.y})`);
145
+ switch (action.kind) {
146
+ case 'tap':
147
+ return `tap${where(action.at)}`;
148
+ case 'type':
149
+ return `type ${JSON.stringify(action.text.length > 40 ? `${action.text.slice(0, 40)}…` : action.text)} into${where(action.at)}`;
150
+ case 'swipe':
151
+ return `swipe ${action.direction}${action.from ? ` from${where(action.from)}` : ''}`;
152
+ case 'key':
153
+ return `press ${action.key}`;
154
+ case 'back':
155
+ return 'back';
156
+ case 'open_url':
157
+ return `open ${action.url}`;
158
+ }
159
+ }
160
+ const PLATFORM_LABEL = { ios: 'iPhone simulator', android: 'Android emulator', web: 'web browser' };
161
+ function instructionPrompt(platform, grounding) {
162
+ return [
163
+ `You operate a ${PLATFORM_LABEL[platform]} to carry out a task. Every turn you see the current screenshot and the actions taken so far, and you call exactly one tool.`,
164
+ grounding === 'revyl'
165
+ ? 'To point at an element, describe it in target by its visible text or look, for example "General row" or "Search field". Do not give coordinates.'
166
+ : 'To point at an element, give the pixel coordinates of its center in the screenshot, x to the right and y down from the top-left corner.',
167
+ 'Check the screenshot before each action: the previous action may not have done what you expected. To see content further down, swipe up.',
168
+ 'Call done with success true as soon as the screenshot shows the task is complete. Call done with success false and a reason if it cannot be done.',
169
+ ].join('\n');
170
+ }
171
+ function validationPrompt(platform) {
172
+ return `You check whether a statement holds on the current screen of a ${PLATFORM_LABEL[platform]}. Judge only what the screenshot shows, then call verdict with passed and a one-sentence reason.`;
173
+ }
174
+ function turnText(type, task, image, history) {
175
+ const lines = [type === 'validation' ? `Statement: ${task}` : `Task: ${task}`, `The screenshot is ${image.width}x${image.height} pixels.`];
176
+ if (type === 'instruction')
177
+ lines.push(history.length ? `Actions so far:\n${history.slice(-30).join('\n')}` : 'No actions yet.');
178
+ else if (history.length)
179
+ lines.push(`Earlier attempts:\n${history.join('\n')}`);
180
+ return lines.join('\n');
181
+ }
182
+ const VERDICT_TOOL = {
183
+ name: 'verdict',
184
+ description: 'Report whether the statement holds on the screen.',
185
+ parameters: { type: 'object', properties: { passed: { type: 'boolean' }, reason: { type: 'string' } }, required: ['passed', 'reason'] },
186
+ };
187
+ function instructionTools(platform, grounding) {
188
+ const at = grounding === 'revyl'
189
+ ? { properties: { target: { type: 'string', description: 'The element, by its visible text or look.' } }, required: ['target'] }
190
+ : { properties: { x: { type: 'integer', description: 'Screenshot pixel x.' }, y: { type: 'integer', description: 'Screenshot pixel y.' } }, required: ['x', 'y'] };
191
+ const tools = [
192
+ { name: 'tap', description: 'Tap an element.', parameters: { type: 'object', ...at } },
193
+ {
194
+ name: 'type',
195
+ description: 'Tap a text field and type into it. Clears the field first unless clear is false.',
196
+ parameters: { type: 'object', properties: { ...at.properties, text: { type: 'string' }, clear: { type: 'boolean' } }, required: [...at.required, 'text'] },
197
+ },
198
+ {
199
+ name: 'swipe',
200
+ description: "Swipe from a point, or from the screen's center. up moves the finger up, which scrolls content down.",
201
+ parameters: { type: 'object', properties: { direction: { type: 'string', enum: ['up', 'down', 'left', 'right'] }, ...at.properties }, required: ['direction'] },
202
+ },
203
+ { name: 'press_key', description: 'Press a key on the focused field.', parameters: { type: 'object', properties: { key: { type: 'string', enum: ['enter', 'backspace'] } }, required: ['key'] } },
204
+ { name: 'open_url', description: platform === 'web' ? 'Load a URL in the current tab.' : 'Open a URL or deep link on the device.', parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] } },
205
+ { name: 'done', description: 'Finish the task.', parameters: { type: 'object', properties: { success: { type: 'boolean' }, reason: { type: 'string' } }, required: ['success', 'reason'] } },
206
+ ];
207
+ if (platform !== 'ios')
208
+ tools.splice(4, 0, { name: 'back', description: platform === 'web' ? 'Go back in browser history.' : 'Press the Android back button.', parameters: { type: 'object', properties: {} } });
209
+ return tools;
210
+ }
211
+ function bounded(value, min, max, name) {
212
+ if (!Number.isInteger(value) || value < min || value > max)
213
+ throw new VerifyError('INVALID_ARGUMENT', `localAgent() ${name} must be an integer from ${min} to ${max}.`);
214
+ return value;
215
+ }
@@ -0,0 +1,91 @@
1
+ /** A JSON Schema object describing one tool's arguments. */
2
+ export type JsonSchema = Record<string, unknown>;
3
+ export interface ModelTool {
4
+ name: string;
5
+ description: string;
6
+ parameters: JsonSchema;
7
+ }
8
+ /** One stateless request: the system prompt, the task and history as text, and the current screenshot. */
9
+ export interface ModelRequest {
10
+ system: string;
11
+ text: string;
12
+ image: {
13
+ png: Uint8Array;
14
+ width: number;
15
+ height: number;
16
+ };
17
+ tools: ModelTool[];
18
+ }
19
+ export interface ModelToolCall {
20
+ name: string;
21
+ arguments: Record<string, unknown>;
22
+ }
23
+ export interface ModelUsage {
24
+ inputTokens: number;
25
+ outputTokens: number;
26
+ }
27
+ export interface ModelReply {
28
+ /** The tool the model chose, if it chose one. */
29
+ toolCall?: ModelToolCall;
30
+ text?: string;
31
+ usage: ModelUsage;
32
+ /** True when the reply stopped at the output token cap. */
33
+ truncated?: boolean;
34
+ }
35
+ /**
36
+ * The only contract a model has to meet to drive a Verify session. Implement it to plug in any provider,
37
+ * a local model, or an agent runtime. Each call is independent: Verify sends the full context every turn.
38
+ */
39
+ export interface ModelAdapter {
40
+ /** Shown in logs, step results and the Revyl session report, so never put a secret in it. */
41
+ readonly name: string;
42
+ complete(request: ModelRequest, signal: AbortSignal): Promise<ModelReply>;
43
+ }
44
+ export interface AnthropicOptions {
45
+ /** For example 'claude-sonnet-4-5'. */
46
+ model: string;
47
+ /** Defaults to the ANTHROPIC_API_KEY environment variable. Sent only to baseURL. */
48
+ apiKey?: string;
49
+ /** Defaults to the ANTHROPIC_BASE_URL environment variable, then https://api.anthropic.com. */
50
+ baseURL?: string;
51
+ /** Output token cap per turn, thinking included. Defaults to 4096. */
52
+ maxTokens?: number;
53
+ /** Attempts after a rate limit, a 5xx or a network error. Defaults to 2. */
54
+ maxRetries?: number;
55
+ }
56
+ /** Claude through Anthropic's Messages API. */
57
+ export declare function anthropic(options: AnthropicOptions): ModelAdapter;
58
+ export interface OpenAICompatibleOptions {
59
+ /** For example 'gpt-5.5', 'qwen-3.8-27b' or 'qwen2.5vl:7b'. */
60
+ model: string;
61
+ /**
62
+ * The API root that serves /chat/completions. Defaults to the OPENAI_BASE_URL environment variable, then https://api.openai.com/v1;
63
+ * Ollama is http://localhost:11434/v1.
64
+ */
65
+ baseURL?: string;
66
+ /**
67
+ * Sent as a bearer token only to baseURL. Defaults to OPENAI_API_KEY when baseURL is left out,
68
+ * and to no key otherwise, so a key for one provider never reaches another.
69
+ */
70
+ apiKey?: string;
71
+ /** Extra headers sent only to baseURL, such as a gateway's api-key header. Never logged. */
72
+ headers?: Record<string, string>;
73
+ /** Prefixes the model in logs and reports, such as 'cerebras'. The baseURL itself is never reported. */
74
+ provider?: string;
75
+ /** Output token cap per turn, reasoning included. Defaults to 4096 for OpenAI and 2048 elsewhere. */
76
+ maxTokens?: number;
77
+ /** Attempts after a rate limit, a 5xx or a network error. Defaults to 2. */
78
+ maxRetries?: number;
79
+ }
80
+ /** Any server that speaks OpenAI's chat completions with tools and image input: OpenAI, Ollama, vLLM, OpenRouter, Cerebras and others. */
81
+ export declare function openaiCompatible(options: OpenAICompatibleOptions): ModelAdapter;
82
+ /** Request differences between OpenAI-compatible servers. */
83
+ export interface ChatCompletionsQuirks {
84
+ /** OpenAI's reasoning models reject max_tokens. */
85
+ tokenField?: 'max_tokens' | 'max_completion_tokens';
86
+ /** Gemini documents only 'auto'. */
87
+ toolChoice?: 'required' | 'auto';
88
+ /** Where the key came from, named in errors when the provider rejects it. */
89
+ keySource?: string;
90
+ }
91
+ export declare function chatCompletions(options: OpenAICompatibleOptions, quirks: ChatCompletionsQuirks): ModelAdapter;
@@ -0,0 +1,255 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+ import { VerifyError, errorMessage } from '../errors.js';
3
+ /** Claude through Anthropic's Messages API. */
4
+ export function anthropic(options) {
5
+ const model = requireText(options.model, 'anthropic() model');
6
+ const apiKey = (options.apiKey ?? process.env.ANTHROPIC_API_KEY ?? '').trim();
7
+ if (!apiKey)
8
+ throw new VerifyError('MODEL_KEY_MISSING', 'anthropic() needs apiKey or ANTHROPIC_API_KEY in the environment. Create a key in the Anthropic Console.');
9
+ const urlSource = options.baseURL === undefined && process.env.ANTHROPIC_BASE_URL?.trim() ? 'ANTHROPIC_BASE_URL' : undefined;
10
+ const { endpoint, appended } = endpointUrl(options.baseURL ?? (urlSource ? process.env.ANTHROPIC_BASE_URL : 'https://api.anthropic.com'), 'messages', 'unless-versioned', urlSource);
11
+ const maxTokens = options.maxTokens ?? 4096;
12
+ const name = `anthropic/${model}`;
13
+ const call = { endpoint, appended, name, model, secrets: [apiKey], keySource: options.apiKey === undefined ? 'ANTHROPIC_API_KEY' : 'the apiKey you passed', urlSource, maxRetries: options.maxRetries };
14
+ return {
15
+ name,
16
+ async complete(turn, signal) {
17
+ const body = await postJson(call, {
18
+ model,
19
+ max_tokens: maxTokens,
20
+ system: turn.system,
21
+ tools: turn.tools.map((tool) => ({ name: tool.name, description: tool.description, input_schema: tool.parameters })),
22
+ tool_choice: { type: 'any' },
23
+ messages: [{
24
+ role: 'user',
25
+ content: [
26
+ { type: 'image', source: { type: 'base64', media_type: 'image/png', data: Buffer.from(turn.image.png).toString('base64') } },
27
+ { type: 'text', text: turn.text },
28
+ ],
29
+ }],
30
+ }, { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' }, signal);
31
+ const content = Array.isArray(body.content) ? body.content : [];
32
+ const toolUse = content.find((block) => block.type === 'tool_use');
33
+ const text = content.filter((block) => block.type === 'text').map((block) => String(block.text ?? '')).join('\n').trim();
34
+ const usage = (body.usage ?? {});
35
+ return {
36
+ ...(toolUse && typeof toolUse.name === 'string' ? { toolCall: { name: toolUse.name, arguments: asRecord(toolUse.input) } } : {}),
37
+ ...(text ? { text } : {}),
38
+ usage: { inputTokens: count(usage.input_tokens) + count(usage.cache_creation_input_tokens) + count(usage.cache_read_input_tokens), outputTokens: count(usage.output_tokens) },
39
+ ...(body.stop_reason === 'max_tokens' ? { truncated: true } : {}),
40
+ };
41
+ },
42
+ };
43
+ }
44
+ /** Any server that speaks OpenAI's chat completions with tools and image input: OpenAI, Ollama, vLLM, OpenRouter, Cerebras and others. */
45
+ export function openaiCompatible(options) {
46
+ return chatCompletions(options, {});
47
+ }
48
+ export function chatCompletions(options, quirks) {
49
+ const model = requireText(options.model, 'openaiCompatible() model');
50
+ const openAI = options.baseURL === undefined;
51
+ const urlSource = openAI && process.env.OPENAI_BASE_URL?.trim() ? 'OPENAI_BASE_URL' : undefined;
52
+ const root = options.baseURL ?? (urlSource ? process.env.OPENAI_BASE_URL : 'https://api.openai.com/v1');
53
+ const apiKey = (options.apiKey ?? (openAI ? process.env.OPENAI_API_KEY : undefined) ?? '').trim();
54
+ const { endpoint, appended } = endpointUrl(root, 'chat/completions', 'at-host-root', urlSource);
55
+ const openAIModels = openAI || endpoint.hostname === 'api.openai.com';
56
+ const extraHeaders = checkHeaders(options.headers);
57
+ const maxTokens = options.maxTokens ?? (openAIModels ? 4096 : 2048);
58
+ const tokenField = quirks.tokenField ?? (openAIModels ? 'max_completion_tokens' : 'max_tokens');
59
+ const name = options.provider ? `${options.provider}/${model}` : model;
60
+ const keySource = quirks.keySource ?? (options.apiKey !== undefined ? 'the apiKey you passed' : apiKey ? 'OPENAI_API_KEY' : undefined);
61
+ const call = { endpoint, appended, name, model, secrets: [apiKey, ...Object.values(extraHeaders)].filter(Boolean), keySource, urlSource, maxRetries: options.maxRetries };
62
+ return {
63
+ name,
64
+ async complete(turn, signal) {
65
+ const body = await postJson(call, {
66
+ model,
67
+ [tokenField]: maxTokens,
68
+ tool_choice: quirks.toolChoice ?? 'required',
69
+ tools: turn.tools.map((tool) => ({ type: 'function', function: tool })),
70
+ messages: [
71
+ { role: 'system', content: turn.system },
72
+ {
73
+ role: 'user',
74
+ content: [
75
+ { type: 'text', text: turn.text },
76
+ { type: 'image_url', image_url: { url: `data:image/png;base64,${Buffer.from(turn.image.png).toString('base64')}` } },
77
+ ],
78
+ },
79
+ ],
80
+ }, { ...extraHeaders, ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}) }, signal);
81
+ const choices = Array.isArray(body.choices) ? body.choices : [];
82
+ const message = asRecord(choices[0]?.message);
83
+ const calls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
84
+ const fn = asRecord(calls[0]?.function);
85
+ const text = typeof message.content === 'string' ? message.content.trim() : '';
86
+ const usage = asRecord(body.usage);
87
+ const toolCall = typeof fn.name === 'string' ? { name: fn.name, arguments: parseArguments(fn.arguments) } : toolCallFromText(text);
88
+ return {
89
+ ...(toolCall ? { toolCall } : {}),
90
+ ...(text ? { text } : {}),
91
+ usage: { inputTokens: count(usage.prompt_tokens), outputTokens: count(usage.completion_tokens) },
92
+ ...(choices[0]?.finish_reason === 'length' ? { truncated: true } : {}),
93
+ };
94
+ },
95
+ };
96
+ }
97
+ const RETRYABLE = new Set([408, 409, 429, 500, 502, 503, 504, 529]);
98
+ const MAX_RETRY_DELAY_MS = 10_000;
99
+ async function postJson(call, payload, headers, signal) {
100
+ const retries = call.maxRetries ?? 2;
101
+ if (!Number.isInteger(retries) || retries < 0 || retries > 5)
102
+ throw new VerifyError('INVALID_ARGUMENT', 'maxRetries must be an integer from 0 to 5.');
103
+ const where = `${call.name} at ${call.endpoint.host}`;
104
+ try {
105
+ for (let attempt = 0;; attempt += 1) {
106
+ let response;
107
+ try {
108
+ response = await fetch(call.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(payload), redirect: 'error', signal });
109
+ }
110
+ catch (error) {
111
+ if (signal.aborted)
112
+ throw error;
113
+ if (attempt < retries) {
114
+ await delay(1_000 * 3 ** attempt, undefined, { signal });
115
+ continue;
116
+ }
117
+ throw new VerifyError('MODEL_ERROR', `Could not reach ${where}: ${scrub(errorMessage(error), call.secrets)}. Check the baseURL and that the server is running.`);
118
+ }
119
+ const text = await response.text();
120
+ if (response.ok) {
121
+ try {
122
+ return asRecord(JSON.parse(text));
123
+ }
124
+ catch {
125
+ throw new VerifyError('MODEL_ERROR', `${where} returned a response that is not JSON. Check that the baseURL serves OpenAI-style chat completions.`);
126
+ }
127
+ }
128
+ if (RETRYABLE.has(response.status) && attempt < retries) {
129
+ const retryAfter = Number(response.headers.get('retry-after'));
130
+ await delay(Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter * 1_000, MAX_RETRY_DELAY_MS) : 1_000 * 3 ** attempt, undefined, { signal });
131
+ continue;
132
+ }
133
+ const message = scrub(providerMessage(text), call.secrets).slice(0, 300).replace(/[.\s]*$/, '.');
134
+ const step = nextStep(response.status, message, call, retries);
135
+ throw new VerifyError('MODEL_ERROR', `${where} refused the request (HTTP ${response.status}): ${message}${step ? ` ${step}` : ''}`, { httpStatus: response.status });
136
+ }
137
+ }
138
+ catch (error) {
139
+ if (error instanceof VerifyError || !signal.aborted)
140
+ throw error;
141
+ throw new VerifyError('TIMEOUT', `${where} did not answer in time. Raise localAgent() modelTimeout for slow or local models.`, { cause: error });
142
+ }
143
+ }
144
+ function nextStep(status, message, call, retries) {
145
+ if (status === 401 || status === 403) {
146
+ const address = call.urlSource ? ` (from ${call.urlSource})` : '';
147
+ return call.keySource
148
+ ? `${call.endpoint.host}${address} rejected the key from ${call.keySource}. Check that the key is valid for that endpoint.`
149
+ : `${call.endpoint.host}${address} needs a key: pass apiKey or headers.`;
150
+ }
151
+ if (status === 429)
152
+ return `Still rate limited after ${retries} retries. Wait and rerun, run fewer sessions at once, or raise your limit with the provider.`;
153
+ const unsupported = /not support|unsupported|only supported|not enabled|not allowed|cannot (accept|process|read)/i.test(message);
154
+ if (unsupported && /image|vision|multimodal|modalit/i.test(message))
155
+ return `${call.model} does not accept images. Pick a model that takes image input.`;
156
+ if (unsupported && /tool|function/i.test(message))
157
+ return `${call.model} does not support tool calls. Pick a model that does.`;
158
+ if (/model.*(not.?found|not exist|invalid|unknown|not a valid|no such)|(invalid|unknown|no such|not a valid).*model/i.test(message))
159
+ return `Check that '${call.model}' is a model name this provider serves.`;
160
+ if (status === 404) {
161
+ const address = call.urlSource ?? 'the baseURL';
162
+ return `Verify added ${call.appended} to ${address}. Check that '${call.model}' is a model this provider serves, and whether ${address} should include /v1.`;
163
+ }
164
+ if (status >= 500)
165
+ return 'The provider failed; try again later.';
166
+ return '';
167
+ }
168
+ function providerMessage(text) {
169
+ try {
170
+ const body = asRecord(JSON.parse(text));
171
+ const error = body.error;
172
+ const message = typeof error === 'string' ? error : asRecord(error).message ?? body.message;
173
+ if (typeof message === 'string')
174
+ return message;
175
+ return 'no error message';
176
+ }
177
+ catch { }
178
+ const plain = text.replace(/\s+/g, ' ').trim();
179
+ return plain && !plain.startsWith('<') ? plain : 'no error message';
180
+ }
181
+ function scrub(text, secrets) {
182
+ return secrets.reduce((out, secret) => (secret.length >= 4 ? out.split(secret).join('[redacted]') : out), text);
183
+ }
184
+ function checkHeaders(headers) {
185
+ if (headers === undefined)
186
+ return {};
187
+ if (!headers || typeof headers !== 'object' || Array.isArray(headers))
188
+ throw new VerifyError('INVALID_ARGUMENT', 'Model headers must be an object of header names to string values.');
189
+ const entries = Object.entries(headers);
190
+ if (entries.length > 20)
191
+ throw new VerifyError('INVALID_ARGUMENT', 'Pass at most 20 model headers.');
192
+ for (const [name, value] of entries) {
193
+ if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,128}$/.test(name) || typeof value !== 'string' || value.length > 8_192 || /[\r\n\0]/.test(value)) {
194
+ throw new VerifyError('INVALID_ARGUMENT', 'Model headers need valid names and single-line string values.');
195
+ }
196
+ }
197
+ return { ...headers };
198
+ }
199
+ function toolCallFromText(text) {
200
+ const start = text.indexOf('{');
201
+ const end = text.lastIndexOf('}');
202
+ if (start < 0 || end <= start)
203
+ return undefined;
204
+ try {
205
+ const parsed = asRecord(JSON.parse(text.slice(start, end + 1)));
206
+ const name = parsed.name ?? parsed.tool;
207
+ if (typeof name !== 'string')
208
+ return undefined;
209
+ return { name, arguments: parseArguments(parsed.arguments ?? parsed.parameters ?? {}) };
210
+ }
211
+ catch {
212
+ return undefined;
213
+ }
214
+ }
215
+ function parseArguments(value) {
216
+ if (typeof value !== 'string')
217
+ return asRecord(value);
218
+ try {
219
+ return asRecord(JSON.parse(value));
220
+ }
221
+ catch {
222
+ return {};
223
+ }
224
+ }
225
+ function asRecord(value) {
226
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
227
+ }
228
+ function count(value) {
229
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0;
230
+ }
231
+ function requireText(value, what) {
232
+ if (typeof value !== 'string' || value.trim() === '')
233
+ throw new VerifyError('INVALID_ARGUMENT', `${what} must be a non-empty string.`);
234
+ return value.trim();
235
+ }
236
+ /**
237
+ * Builds the request URL from a base address given with or without its /v1 suffix.
238
+ * unless-versioned: Anthropic's habit; add v1/ unless the address already ends in a version such as /v1.
239
+ * at-host-root: OpenAI-compatible; add v1/ only to a bare host, since other paths (/openai/v1, /v1beta/openai) are already complete.
240
+ */
241
+ function endpointUrl(root, path, version, source = 'The model baseURL') {
242
+ let url;
243
+ try {
244
+ url = new URL(root.trim().endsWith('/') ? root.trim() : `${root.trim()}/`);
245
+ }
246
+ catch {
247
+ throw new VerifyError('INVALID_ARGUMENT', `${source} is not a valid URL.`);
248
+ }
249
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password)
250
+ throw new VerifyError('INVALID_ARGUMENT', `${source} must be an http(s) URL without credentials.`);
251
+ const versioned = /\/v\d+[a-z0-9]*\/$/i.test(url.pathname);
252
+ const addVersion = version === 'unless-versioned' ? !versioned : url.pathname === '/';
253
+ const appended = addVersion ? `v1/${path}` : path;
254
+ return { endpoint: new URL(appended, url), appended: `/${appended}` };
255
+ }
@@ -0,0 +1,23 @@
1
+ import { type Grounding, type LocalAgent } from './loop.js';
2
+ /** Your own OpenAI-compatible endpoint, such as vLLM, LiteLLM or a gateway. */
3
+ export interface CustomModel {
4
+ /** The model name the endpoint expects. */
5
+ id: string;
6
+ /** The API root that serves /chat/completions. */
7
+ baseURL: string;
8
+ /** Sent as a bearer token only to baseURL. Never read from the environment, logged or sent to Revyl. */
9
+ apiKey?: string;
10
+ /** Extra headers sent only to baseURL. */
11
+ headers?: Record<string, string>;
12
+ /** Prefixes the model in step results and reports, such as 'anthropic' for 'anthropic/claude-sonnet-5-5'. */
13
+ provider?: string;
14
+ /** 'revyl' (default) has the model name elements for Revyl to locate; 'model' has it give screen coordinates. */
15
+ grounding?: Grounding;
16
+ }
17
+ /** A built-in 'provider/model' string, such as 'anthropic/claude-sonnet-5-5', or your own endpoint. */
18
+ export type ModelOption = string | CustomModel;
19
+ /** Picks the local agent for a step or session: a model option, an agent, or neither for Revyl's agent. */
20
+ export declare function chooseAgent(options: {
21
+ model?: ModelOption;
22
+ agent?: LocalAgent;
23
+ }, where: string): LocalAgent | undefined;
@@ -0,0 +1,58 @@
1
+ import { VerifyError } from '../errors.js';
2
+ import { localAgent } from './loop.js';
3
+ import { anthropic, chatCompletions } from './model.js';
4
+ const PROVIDERS = {
5
+ anthropic: { label: 'Anthropic', env: ['ANTHROPIC_API_KEY'] },
6
+ openai: { label: 'OpenAI', env: ['OPENAI_API_KEY'] },
7
+ google: { label: 'Google AI Studio', env: ['GOOGLE_API_KEY', 'GEMINI_API_KEY'], baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai/', quirks: { toolChoice: 'auto' }, maxTokens: 4096 },
8
+ openrouter: { label: 'OpenRouter', env: ['OPENROUTER_API_KEY'], baseURL: 'https://openrouter.ai/api/v1' },
9
+ groq: { label: 'Groq', env: ['GROQ_API_KEY'], baseURL: 'https://api.groq.com/openai/v1' },
10
+ cerebras: { label: 'Cerebras', env: ['CEREBRAS_API_KEY'], baseURL: 'https://api.cerebras.ai/v1' },
11
+ xai: { label: 'xAI', env: ['XAI_API_KEY'], baseURL: 'https://api.x.ai/v1' },
12
+ ollama: { label: 'Ollama', env: [], baseURL: 'http://localhost:11434/v1' },
13
+ };
14
+ /** Picks the local agent for a step or session: a model option, an agent, or neither for Revyl's agent. */
15
+ export function chooseAgent(options, where) {
16
+ if (options.model !== undefined && options.agent !== undefined) {
17
+ throw new VerifyError('INVALID_ARGUMENT', `${where} takes model or agent, not both.`);
18
+ }
19
+ if (options.model !== undefined)
20
+ return agentForModel(options.model);
21
+ if (options.agent !== undefined && typeof options.agent?.model?.complete !== 'function') {
22
+ throw new VerifyError('INVALID_ARGUMENT', `${where} takes an agent made with localAgent().`);
23
+ }
24
+ return options.agent;
25
+ }
26
+ const USAGE = `Pass 'provider/model', such as 'anthropic/claude-sonnet-5-5', with a provider of ${Object.keys(PROVIDERS).join(', ')}, or { id, baseURL } for your own OpenAI-compatible endpoint.`;
27
+ function agentForModel(option) {
28
+ if (typeof option === 'string') {
29
+ const slash = option.indexOf('/');
30
+ const name = slash > 0 ? option.slice(0, slash) : '';
31
+ const id = option.slice(slash + 1).trim();
32
+ const provider = Object.hasOwn(PROVIDERS, name) ? PROVIDERS[name] : undefined;
33
+ if (!provider || !id) {
34
+ throw new VerifyError('INVALID_ARGUMENT', `Unknown model '${option.slice(0, 100)}'. ${USAGE}`);
35
+ }
36
+ const keyVariable = provider.env.find((variable) => process.env[variable]?.trim());
37
+ if (provider.env.length > 0 && !keyVariable) {
38
+ throw new VerifyError('MODEL_KEY_MISSING', `model '${option}' reads its key from ${provider.env.join(' or ')}, which is not set. Set it to your ${provider.label} API key.`);
39
+ }
40
+ const apiKey = keyVariable ? process.env[keyVariable].trim() : undefined;
41
+ if (name === 'anthropic')
42
+ return localAgent({ model: anthropic({ model: id }) });
43
+ return localAgent({
44
+ model: chatCompletions({ model: id, ...(provider.baseURL ? { baseURL: provider.baseURL } : {}), provider: name, ...(apiKey ? { apiKey } : {}), ...(provider.maxTokens ? { maxTokens: provider.maxTokens } : {}) }, { ...provider.quirks, ...(keyVariable ? { keySource: keyVariable } : {}) }),
45
+ });
46
+ }
47
+ if (!option || typeof option !== 'object' || typeof option.id !== 'string' || typeof option.baseURL !== 'string') {
48
+ throw new VerifyError('INVALID_ARGUMENT', `model must be a string or an object. ${USAGE}`);
49
+ }
50
+ if (option.apiKey !== undefined && typeof option.apiKey !== 'string')
51
+ throw new VerifyError('INVALID_ARGUMENT', 'model apiKey must be a string.');
52
+ if (option.provider !== undefined && (typeof option.provider !== 'string' || !option.provider.trim()))
53
+ throw new VerifyError('INVALID_ARGUMENT', 'model provider must be a non-empty string.');
54
+ return localAgent({
55
+ model: chatCompletions({ model: option.id, baseURL: option.baseURL, ...(option.apiKey !== undefined ? { apiKey: option.apiKey } : {}), ...(option.headers ? { headers: option.headers } : {}), ...(option.provider ? { provider: option.provider.trim() } : {}) }, {}),
56
+ ...(option.grounding ? { grounding: option.grounding } : {}),
57
+ });
58
+ }
@@ -52,6 +52,7 @@ export declare class RevylClient {
52
52
  timeoutMs?: number;
53
53
  signal?: AbortSignal;
54
54
  retry?: boolean;
55
+ headers?: Record<string, string>;
55
56
  }): Promise<RawResponse>;
56
57
  private sendOnce;
57
58
  }