@revyl/verify 0.3.2 → 0.4.1

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,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
  }
@@ -91,7 +91,7 @@ export class RevylClient {
91
91
  timeoutMs: options.timeoutMs,
92
92
  signal: options.signal,
93
93
  retry: options.retry ?? false,
94
- headers: { 'X-Revyl-Agent': 'Verify' },
94
+ headers: { 'X-Revyl-Agent': 'Verify', ...options.headers },
95
95
  });
96
96
  }
97
97
  async sendOnce(method, url, options) {
@@ -198,7 +198,7 @@ export function edgeBlockedError(response) {
198
198
  }
199
199
  const REFUSAL_REASONS = new Set([
200
200
  'web_runtime_unavailable', 'web_runtime_not_configured', 'computer_limit', 'runtime_unavailable', 'computer_runtime_unavailable',
201
- 'computer_at_capacity', 'computer_not_running', 'computer_cannot_host_platform', 'computer_not_found',
201
+ 'computer_at_capacity', 'computer_platform_at_capacity', 'computer_not_running', 'computer_cannot_host_platform', 'computer_not_found',
202
202
  'web_build_required', 'web_build_not_supported', 'ios_build_required', 'ios_build_not_supported',
203
203
  'android_build_required', 'android_build_not_supported', 'web_session_conflict', 'ios_session_conflict',
204
204
  'android_session_conflict', 'not_available_on_platform', 'request_timeout', 'protocol_error',
package/dist/auth.js CHANGED
@@ -28,8 +28,8 @@ export async function resolveCredentials(options = {}) {
28
28
  export function missingCredentialsMessage() {
29
29
  return [
30
30
  'No Revyl credentials found.',
31
- 'Set REVYL_API_KEY to an API key from https://app.revyl.ai (Settings → API keys),',
32
- "or sign in once with the Revyl CLI: 'revyl auth login'.",
31
+ "Sign in through your browser with 'npx @revyl/verify login' in a terminal,",
32
+ 'or set REVYL_API_KEY to an API key from https://app.revyl.ai (Settings → API keys).',
33
33
  ].join(' ');
34
34
  }
35
35
  async function readStoredCredentials(path) {
@@ -8,6 +8,7 @@ export interface InitResult {
8
8
  created: string[];
9
9
  gitignoreUpdated: boolean;
10
10
  }
11
+ export declare function webAppIdMissingMessage(url: string | undefined): string;
11
12
  export declare function validateInitAnswers(answers: InitAnswers): InitAnswers;
12
13
  export declare function initProject(cwd: string, answers: InitAnswers, options?: {
13
14
  force?: boolean;
package/dist/cli/init.js CHANGED
@@ -5,7 +5,15 @@ import { VerifyError } from '../errors.js';
5
5
  import { PACKAGE_NAME } from '../version.js';
6
6
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7
7
  const CONFIG_FILE = 'verify.config.ts';
8
- const EXAMPLE_SCRIPT = join('verify', 'example.ts');
8
+ const EXAMPLE_SCRIPT = join('verify', 'example.mts');
9
+ const SETTINGS_BUNDLE_ID = { ios: 'com.apple.Preferences', android: 'com.android.settings' };
10
+ export function webAppIdMissingMessage(url) {
11
+ return [
12
+ 'A Web target needs the id of a Web app in Revyl, so init wrote no files.',
13
+ 'Create one at https://app.revyl.ai: click Create app, keep Web selected, and enter your site\'s public HTTPS URL. Then copy the app id from the app\'s page.',
14
+ `Then run: npx ${PACKAGE_NAME} init --platform web --app-id <app-id> --url ${url ?? 'https://example.com'}`,
15
+ ].join('\n');
16
+ }
9
17
  export function validateInitAnswers(answers) {
10
18
  if (!['web', 'ios', 'android', 'mac'].includes(answers.platform)) {
11
19
  throw new VerifyError('INVALID_ARGUMENT', "--platform must be 'web', 'ios', 'android', or 'mac'. Linux init is unavailable; use open({ platform: 'linux' }) or a Linux config target.");
@@ -23,13 +31,16 @@ export function validateInitAnswers(answers) {
23
31
  throw new VerifyError('INVALID_ARGUMENT', '--url must be a public HTTPS URL without credentials, query, or fragment.');
24
32
  }
25
33
  }
34
+ if (answers.platform === 'web' && answers.appId === undefined) {
35
+ throw new VerifyError('INVALID_ARGUMENT', webAppIdMissingMessage(answers.url));
36
+ }
26
37
  return answers;
27
38
  }
28
39
  export async function initProject(cwd, answers, options = {}) {
29
40
  validateInitAnswers(answers);
30
41
  const files = [
31
42
  [CONFIG_FILE, configTemplate(answers)],
32
- [EXAMPLE_SCRIPT, scriptTemplate(answers.platform)],
43
+ [EXAMPLE_SCRIPT, scriptTemplate(answers)],
33
44
  ];
34
45
  const existing = files.map(([path]) => path).filter((path) => existsSync(join(cwd, path)));
35
46
  if (existing.length > 0 && !options.force) {
@@ -50,25 +61,33 @@ async function ignoreOutput(cwd) {
50
61
  return true;
51
62
  }
52
63
  function configTemplate({ platform, appId, url }) {
53
- const app = appId ? `'${appId}'` : `process.env.REVYL_APP_ID ?? 'REPLACE_WITH_YOUR_REVYL_APP_ID'`;
54
64
  const target = platform === 'mac'
55
65
  ? ` mac: { platform: 'mac' },`
56
66
  : platform === 'web'
57
67
  ? ` web: {
58
68
  platform: 'web',
59
69
  app: {
60
- // The Revyl web app to open: copy its id from the app's page in Revyl.
61
- appId: ${app},
70
+ // The Revyl Web app to open.
71
+ appId: '${appId}',
62
72
  // A public HTTPS URL, such as a preview deployment. Remove it to open the app's newest Revyl build.
63
73
  url: '${url ?? 'https://example.com'}',
64
74
  },
65
75
  viewport: { width: 1280, height: 800 },
66
76
  },`
67
- : ` ${platform}: {
77
+ : appId
78
+ ? ` ${platform}: {
68
79
  platform: '${platform}',
69
80
  app: {
70
81
  // The Revyl app to install: each session installs its newest build. Use buildId to pin one build.
71
- appId: ${app},
82
+ appId: '${appId}',
83
+ },
84
+ },`
85
+ : ` ${platform}: {
86
+ platform: '${platform}',
87
+ app: {
88
+ // The device's built-in Settings app, so the example runs before you upload anything.
89
+ // To test your own app, replace this line with appId: '<your Revyl app id>'.
90
+ bundleId: '${SETTINGS_BUNDLE_ID[platform]}',
72
91
  },
73
92
  },`;
74
93
  return `import { defineConfig } from '${PACKAGE_NAME}';
@@ -80,7 +99,7 @@ ${target}
80
99
  });
81
100
  `;
82
101
  }
83
- function scriptTemplate(platform) {
102
+ function scriptTemplate({ platform, appId }) {
84
103
  if (platform === 'mac') {
85
104
  return `// Mac computers are in early access; ask support@revyl.ai to enable your organization.
86
105
  import assert from 'node:assert/strict';
@@ -101,7 +120,13 @@ try {
101
120
  const steps = platform === 'web'
102
121
  ? `await session.instruction('Follow the link on the page');
103
122
  await session.validation('A page about domains reserved for documentation is shown');`
104
- : `await session.instruction('Wait for the app to finish opening and show its first screen');
123
+ : !appId && platform === 'ios'
124
+ ? `await session.instruction('Open General, then About');
125
+ await session.validation('The screen shows the operating system version');`
126
+ : !appId && platform === 'android'
127
+ ? `await session.instruction('Open the screen that shows information about this device, such as About or About phone, then scroll or navigate until the operating system version is visible');
128
+ await session.validation('The screen shows the version of the operating system');`
129
+ : `await session.instruction('Wait for the app to finish opening and show its first screen');
105
130
  await session.validation('The app has opened and shows its first screen without an error');`;
106
131
  return `import { open } from '${PACKAGE_NAME}';
107
132
 
package/dist/cli/login.js CHANGED
@@ -53,7 +53,7 @@ export async function signIn(options = {}) {
53
53
  continue;
54
54
  }
55
55
  if (credential.state === 'denied')
56
- throw new VerifyError('AUTH_REJECTED', 'The Revyl login request was denied. Run your script again to sign in, or use revyl auth login.');
56
+ throw new VerifyError('AUTH_REJECTED', 'The Revyl login request was denied. Run your script again to sign in, or run npx @revyl/verify login.');
57
57
  if (credential.state !== 'approved')
58
58
  throw invalidResponse();
59
59
  const token = requiredString(credential, 'api_key_token');
@@ -80,11 +80,11 @@ export async function signIn(options = {}) {
80
80
  await writePrivateState('credentials.json', stored);
81
81
  return { token, source: 'revyl-cli-login' };
82
82
  }
83
- throw new VerifyError('AUTH_MISSING', 'The Revyl login request expired. Run your script again to sign in, or use revyl auth login.');
83
+ throw new VerifyError('AUTH_MISSING', 'The Revyl login request expired. Run your script again to sign in, or run npx @revyl/verify login.');
84
84
  }
85
85
  catch (error) {
86
86
  if (signal.aborted)
87
- throw new VerifyError('AUTH_MISSING', 'Revyl sign-in was cancelled or timed out. Run your script again to sign in, or use revyl auth login.');
87
+ throw new VerifyError('AUTH_MISSING', 'Revyl sign-in was cancelled or timed out. Run your script again to sign in, or run npx @revyl/verify login.');
88
88
  throw error;
89
89
  }
90
90
  finally {
@@ -98,12 +98,12 @@ async function post(backendUrl, path, body, signal) {
98
98
  response = await fetch(new URL(path, backendUrl), { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body), redirect: 'error', signal: AbortSignal.any([signal, AbortSignal.timeout(15_000)]) });
99
99
  }
100
100
  catch {
101
- throw new VerifyError('AUTH_MISSING', 'Could not reach Revyl to sign in. Run your script again, use revyl auth login, or set REVYL_API_KEY.');
101
+ throw new VerifyError('AUTH_MISSING', 'Could not reach Revyl to sign in. Run your script again, run npx @revyl/verify login, or set REVYL_API_KEY.');
102
102
  }
103
103
  if (response.status === 404)
104
- throw new VerifyError('AUTH_MISSING', 'The Revyl login request is unavailable or expired. Run your script again, or use revyl auth login.');
104
+ throw new VerifyError('AUTH_MISSING', 'The Revyl login request is unavailable or expired. Run your script again, or run npx @revyl/verify login.');
105
105
  if (!response.ok)
106
- throw new VerifyError('AUTH_REJECTED', `Revyl sign-in failed (HTTP ${response.status}). Run your script again, use revyl auth login, or set REVYL_API_KEY.`);
106
+ throw new VerifyError('AUTH_REJECTED', `Revyl sign-in failed (HTTP ${response.status}). Run your script again, run npx @revyl/verify login, or set REVYL_API_KEY.`);
107
107
  let value;
108
108
  try {
109
109
  value = await response.json();
@@ -130,7 +130,7 @@ function optionalString(value, key) {
130
130
  return text || undefined;
131
131
  }
132
132
  function invalidResponse() {
133
- return new VerifyError('AUTH_REJECTED', 'Revyl returned an invalid sign-in response. Run your script again, use revyl auth login, or set REVYL_API_KEY.');
133
+ return new VerifyError('AUTH_REJECTED', 'Revyl returned an invalid sign-in response. Run your script again, run npx @revyl/verify login, or set REVYL_API_KEY.');
134
134
  }
135
135
  function tokenExpiry(token) {
136
136
  try {