From 827a459f807eafcaacb78e7c03e745787cc3e179 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Thu, 8 Oct 2026 02:03:29 +0300 Subject: [PATCH 1/5] feat(Decision): use AI SDK experimental_decide Replace hand-written fetch calls to OpenRouter/TypeSafe decision endpoints with AI SDK experimental_decide via @openrouter/ai-sdk-provider and @ai-sdk/typesafe-ai. Bumps ai to v7, which experimental_decide requires. Co-Authored-By: Claude Opus 5.5 --- lib/ai.js | 72 ++++++++++++++++--------------- package.json | 4 +- test/unit/ai_test.js | 2 +- test/unit/helper/Decision_test.js | 23 ++++------ 4 files changed, 50 insertions(+), 51 deletions(-) diff --git a/lib/ai.js b/lib/ai.js index dcff6af94..7f76c0ae3 100644 --- a/lib/ai.js +++ b/lib/ai.js @@ -3,7 +3,9 @@ const debug = debugModule('codeceptjs:ai') import output from './output.js' import event from './event.js' import { removeNonInteractiveElements, minifyHtml, splitByChunks } from './html.js' -import { generateText } from 'ai' +import { generateText, experimental_decide as decide, APICallError } from 'ai' +import { createOpenRouter } from '@openrouter/ai-sdk-provider' +import { createTypeSafeAi } from '@ai-sdk/typesafe-ai' import { fileURLToPath } from 'url' import path from 'path' import { fileExists, resolveImportModulePath } from './utils.js' @@ -271,9 +273,9 @@ function parseCodeBlocks(response) { return modifiedSnippets.filter(snippet => !!snippet) } -const DECISION_ENDPOINTS = { - openrouter: { url: 'https://openrouter.ai/api/alpha/decisions', keyName: 'OPENROUTER_API_KEY' }, - typesafe: { url: 'https://api.typesafe.ai/v1/systemone', keyName: 'TYPESAFE_API_KEY' }, +const DECISION_PROVIDERS = { + openrouter: { keyName: 'OPENROUTER_API_KEY' }, + typesafe: { keyName: 'TYPESAFE_API_KEY' }, } const defaultDecisionConfig = { @@ -292,20 +294,20 @@ class DecisionAI { this.config = { ...defaultDecisionConfig, ...config } const { provider, confidence } = this.config - this.endpoint = DECISION_ENDPOINTS[provider] - if (!this.endpoint) throw new Error(`Unknown decision provider "${provider}" in ai.decisionModel, use one of: ${Object.keys(DECISION_ENDPOINTS).join(', ')}`) + this.provider = DECISION_PROVIDERS[provider] + if (!this.provider) throw new Error(`Unknown decision provider "${provider}" in ai.decisionModel, use one of: ${Object.keys(DECISION_PROVIDERS).join(', ')}`) if (!(confidence > 0 && confidence < 1)) throw new Error(`ai.decisionModel.confidence must be between 0 and 1, got ${confidence}`) this.fetchImpl = fetch } checkModel() { - if (this.config.apiKey || process.env[this.endpoint.keyName]) return + if (this.config.apiKey || process.env[this.provider.keyName]) return const noKeyErrorMessage = ` No API key is set for decision model. - [!] Set ${this.endpoint.keyName} environment variable or apiKey in ai.decisionModel config. + [!] Set ${this.provider.keyName} environment variable or apiKey in ai.decisionModel config. Example (connect to OpenRouter, default): @@ -340,8 +342,7 @@ class DecisionAI { async decide(model, state, statements) { this.checkModel() - const apiKey = this.config.apiKey || process.env[this.endpoint.keyName] - const questions = Object.fromEntries(statements.map((statement, i) => [`q${i}`, { type: 'noul', instructions: statement }])) + const questions = Object.fromEntries(statements.map((statement, i) => [`q${i}`, { type: 'boolean', instructions: statement }])) debug('Decision request', model, statements) const controller = new AbortController() @@ -349,39 +350,40 @@ class DecisionAI { let result try { - let response - try { - response = await this.fetchImpl(this.endpoint.url, { - method: 'POST', - headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ model, state, questions }), - signal: controller.signal, - }) - } catch (err) { - throw new DecisionConnectionError(`Decision model ${model} request failed: ${err.message}`) - } - - if (!response.ok) { - const body = await response.text().catch(() => '') - throw new Error(`Decision model ${model} responded with ${response.status}: ${body}`) - } - - result = await response.json() + result = await decide({ + model: this.decisionModel(model), + state, + questions, + maxRetries: 0, + abortSignal: controller.signal, + }) } catch (err) { if (controller.signal.aborted) throw new DecisionConnectionError(`Decision model ${model} did not respond in ${this.config.timeout}ms`) + if (APICallError.isInstance(err)) throw new Error(`Decision model ${model} responded with ${err.statusCode}: ${err.responseBody || err.message}`) throw err } finally { clearTimeout(timer) } - debug('Decision response', result?.answers, result?.usage) + debug('Decision response', result.answers, result.usage) - const answers = result?.answers || {} - return statements.map((statement, i) => { - const probability = answers[`q${i}`]?.noul - if (typeof probability !== 'number') throw new Error(`Decision model ${model} returned no answer for "${statement}"`) - return probability - }) + return Object.keys(questions).map(id => result.answers[id].probability) + } + + decisionModel(model) { + const settings = { + apiKey: this.config.apiKey || process.env[this.provider.keyName], + fetch: async (url, options) => { + try { + return await this.fetchImpl(url, options) + } catch (err) { + if (options.signal?.aborted) throw err + throw new DecisionConnectionError(`Decision model ${model} request failed: ${err.message}`) + } + }, + } + if (this.config.provider === 'typesafe') return createTypeSafeAi(settings).decisionModel(model) + return createOpenRouter(settings).evaluationModel(model) } } diff --git a/package.json b/package.json index d6771005d..bbec2bcca 100644 --- a/package.json +++ b/package.json @@ -93,15 +93,17 @@ "publish-beta": "./runok.cjs publish:next-beta-version" }, "dependencies": { + "@ai-sdk/typesafe-ai": "^3.0.15", "@codeceptjs/configure": "^4.0.0-beta.4", "@codeceptjs/helper": "2.0.4", "@cucumber/cucumber-expressions": "18", "@cucumber/gherkin": "38.0.0", "@cucumber/messages": "32.3.1", "@modelcontextprotocol/sdk": "^1.26.0", + "@openrouter/ai-sdk-provider": "^3.1.0", "@xmldom/xmldom": "0.9.10", "acorn": "8.15.0", - "ai": "^6.0.43", + "ai": "^7.0.131", "arrify": "3.0.0", "axios": "1.16.1", "chalk": "4.1.2", diff --git a/test/unit/ai_test.js b/test/unit/ai_test.js index 89b457e13..318271404 100644 --- a/test/unit/ai_test.js +++ b/test/unit/ai_test.js @@ -325,7 +325,7 @@ describe('DecisionAI', () => { const decisionAI = new DecisionAI({ apiKey: 'secret' }) decisionAI.fetchImpl = async (url, options) => { calls.push({ url, body: JSON.parse(options.body) }) - return { ok: true, json: async () => ({ answers: { q0: { type: 'noul', noul: 0.9 }, q1: { type: 'noul', noul: 0.2 } } }) } + return Response.json({ answers: { q0: { type: 'noul', noul: 0.9 }, q1: { type: 'noul', noul: 0.2 } } }) } const probabilities = await decisionAI.decide('typesafe/jev-1.13', { url: 'http://localhost' }, ['form is shown', 'cart is empty']) diff --git a/test/unit/helper/Decision_test.js b/test/unit/helper/Decision_test.js index 01693f639..5db95425b 100644 --- a/test/unit/helper/Decision_test.js +++ b/test/unit/helper/Decision_test.js @@ -13,13 +13,9 @@ function createDecision(decisionModel, config = {}) { function fakeFetch(answers, calls, status = 200) { return async (url, options) => { - calls.push({ url, body: JSON.parse(options.body), headers: options.headers }) - return { - ok: status === 200, - status, - text: async () => 'boom', - json: async () => ({ answers }), - } + calls.push({ url, body: JSON.parse(options.body), headers: new Headers(options.headers) }) + if (status !== 200) return new Response('boom', { status }) + return Response.json({ answers }) } } @@ -53,7 +49,7 @@ describe('Decision helper', () => { expect(probability).to.equal(0.9) expect(calls).to.have.length(1) expect(calls[0].url).to.equal('https://openrouter.ai/api/alpha/decisions') - expect(calls[0].headers.Authorization).to.equal('Bearer secret') + expect(calls[0].headers.get('authorization')).to.equal('Bearer secret') expect(calls[0].body.model).to.equal('typesafe/jev-1.13') expect(calls[0].body.questions).to.eql({ q0: { type: 'noul', instructions: 'submit button is present' } }) expect(calls[0].body.state.url).to.equal('http://localhost/checkout') @@ -146,7 +142,7 @@ describe('Decision helper', () => { it('reports missing answers', async () => { decision.decisionAI.fetchImpl = fakeFetch({}, calls) const err = await decision.decide('page is loaded').catch(e => e) - expect(err.message).to.include('returned no answer for "page is loaded"') + expect(err.message).to.include('Decision must return exactly one answer for every question') }) it('times out hanging requests', async () => { @@ -162,11 +158,10 @@ describe('Decision helper', () => { it('times out stalled response body', async () => { decision = createDecision({ apiKey: 'secret', timeout: 50 }) decision._actingHelper = () => browser - decision.decisionAI.fetchImpl = async (url, { signal }) => ({ - ok: true, - status: 200, - json: () => new Promise((_, reject) => signal.addEventListener('abort', () => reject(new Error('aborted')))), - }) + decision.decisionAI.fetchImpl = async (url, { signal }) => { + const body = new ReadableStream({ start: controller => signal.addEventListener('abort', () => controller.error(new Error('aborted'))) }) + return new Response(body, { headers: { 'content-type': 'application/json' } }) + } const err = await decision.decide('page is loaded').catch(e => e) expect(err.message).to.include('did not respond in 50ms') From 42d920adad0c1a2fc2a82e5427290efb589f90cc Mon Sep 17 00:00:00 2001 From: DavertMik Date: Thu, 8 Oct 2026 03:10:53 +0300 Subject: [PATCH 2/5] feat(Decision): use OpenRouter Decisions API only, document custom decisions Drop the decision provider option and @ai-sdk/typesafe-ai: all decision models, including TypeSafe Jev, are served by the OpenRouter Decisions API. Document how decisions use AI SDK experimental_decide and how to ask choice/score questions from a custom helper. Co-Authored-By: Claude Opus 5.5 --- docs/agents.md | 2 ++ docs/ai.md | 44 ++++++++++++++++++++++++++++++- docs/assertions.md | 7 +++-- docs/helpers/Decision.md | 31 ++++++++++++---------- lib/ai.js | 31 +++------------------- lib/helper/Decision.js | 11 ++++---- package.json | 1 - test/unit/ai_test.js | 18 +++++-------- test/unit/helper/Decision_test.js | 21 ++++----------- 9 files changed, 87 insertions(+), 79 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index 9f20b5b9a..0047e9ff4 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -186,6 +186,8 @@ I.decide([ The agent runs the statement on the live page like any other command and keeps it only if it passes. The test then checks it on every run. +To let the agent use decisions, enable the Decision helper and set `OPENROUTER_API_KEY`. See [Decision Assertions](/assertions#decision-assertions) for the config. + Decision models like [Jev](https://openrouter.ai/typesafe/jev-1.13) are built for this. They answer in a fraction of a second, cost a fraction of a cent per request, and return a probability instead of free text. That makes them fast and cheap enough to run on every CI build, and predictable enough to keep in a test. ## Skills bundle diff --git a/docs/ai.md b/docs/ai.md index e156818fc..9a483c0e9 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -353,7 +353,49 @@ I.decideVisually('sidebar is shown') It uses a [decision model](https://openrouter.ai/models?output_modalities=decisions) like [Jev](https://openrouter.ai/typesafe/jev-1.13) instead of a chat model. A decision model reads the page and returns the probability that a statement is true. It is fast, costs a fraction of a cent per request, and gives a probability instead of free text, so a step passes or fails on a confidence threshold you set. -Configure it in `ai.decisionModel`. Decisions call the decisions API directly, so they don't need `ai.model` or the `--ai` flag. See [Decision Assertions](/assertions#decision-assertions) for setup and usage. +Decisions are requested through the [OpenRouter Decisions API](https://openrouter.ai/models?output_modalities=decisions) with AI SDK [`experimental_decide`](https://ai-sdk.dev/docs/ai-sdk-core/decisions). Set `OPENROUTER_API_KEY` and configure the model in `ai.decisionModel`. Decisions don't need `ai.model` or the `--ai` flag. See [Decision Assertions](/assertions#decision-assertions) for setup and usage. + +### Custom Decisions + +`I.decide` asks yes/no questions. AI SDK decisions also support `choice` and `score` questions. Use [`experimental_decide`](https://ai-sdk.dev/docs/ai-sdk-core/decisions) in a [custom helper](/helpers) to ask them about the current page: + +```js +import Helper from '@codeceptjs/helper' +import { experimental_decide as decide } from 'ai' +import { createOpenRouter } from '@openrouter/ai-sdk-provider' + +class PageDecisions extends Helper { + async grabPageKind() { + const { Playwright } = this.helpers + const openrouter = createOpenRouter() + + const { answers } = await decide({ + model: openrouter.evaluationModel('typesafe/jev-1.13'), + state: { + url: await Playwright.grabCurrentUrl(), + aria: await Playwright.grabAriaSnapshot(), + }, + questions: { + page: { + type: 'choice', + instructions: 'What kind of page is shown?', + criteria: { login: 'sign in form', dashboard: 'user dashboard', error: 'error message' }, + }, + }, + }) + + return answers.page.choice + } +} + +export default PageDecisions +``` + +```js +const kind = await I.grabPageKind() +``` + +`ai` and `@openrouter/ai-sdk-provider` are installed with CodeceptJS. A `score` question returns a fractional position on a list of levels, and a `boolean` question returns a probability. See [AI SDK decisions](https://ai-sdk.dev/docs/ai-sdk-core/decisions) for all question types. ## Advanced Configuration diff --git a/docs/assertions.md b/docs/assertions.md index d18107b2e..ac5c87e16 100644 --- a/docs/assertions.md +++ b/docs/assertions.md @@ -426,15 +426,14 @@ helpers: { | Option | Default | Description | |---|---|---| -| `provider` | `openrouter` | `openrouter` reads `OPENROUTER_API_KEY`, `typesafe` reads `TYPESAFE_API_KEY` | -| `apiKey` | | API key, overrides the environment variable | +| `apiKey` | | OpenRouter API key, overrides `OPENROUTER_API_KEY` | | `model` | `typesafe/jev-1.13` | model for `I.decide` | -| `visualModel` | `cloudflare/clef` | model with image input for `I.decideVisually`, OpenRouter only | +| `visualModel` | `cloudflare/clef` | model with image input for `I.decideVisually` | | `confidence` | `0.7` | minimal probability for a statement to pass | | `timeout` | `15000` | request timeout in ms | | `maxLength` | `12000` | maximal length of ARIA snapshot or HTML sent to the model | -Decisions don't need the `--ai` flag, and `ai.model` is not required. +Any model from the [OpenRouter decision models](https://openrouter.ai/models?output_modalities=decisions) list can be used. Decisions don't need the `--ai` flag, and `ai.model` is not required. Then assert statements about the current page: diff --git a/docs/helpers/Decision.md b/docs/helpers/Decision.md index 0e6de31a2..2fbdbfba8 100644 --- a/docs/helpers/Decision.md +++ b/docs/helpers/Decision.md @@ -41,7 +41,6 @@ Decision model is configured in the `ai.decisionModel` section of the config: ```js ai: { decisionModel: { - provider: 'openrouter', model: 'typesafe/jev-1.13', visualModel: 'cloudflare/clef', confidence: 0.7, @@ -53,10 +52,12 @@ helpers: { } ``` -* `provider` (default: `openrouter`) - decision API to call: `openrouter` (reads `OPENROUTER_API_KEY`) or `typesafe` (reads `TYPESAFE_API_KEY`). -* `apiKey` (optional) - API key, overrides the environment variable. -* `model` (default: `typesafe/jev-1.13`) - decision model used by `decide`. Use `jev-latest` with the `typesafe` provider. -* `visualModel` (default: `cloudflare/clef`) - decision model with image input used by `decideVisually`. Available on OpenRouter only. +Decisions are requested through the [OpenRouter Decisions API][3] +with AI SDK [`experimental_decide`][4]. Set `OPENROUTER_API_KEY` to use it. + +* `apiKey` (optional) - OpenRouter API key, overrides `OPENROUTER_API_KEY` environment variable. +* `model` (default: `typesafe/jev-1.13`) - decision model used by `decide`. +* `visualModel` (default: `cloudflare/clef`) - decision model with image input used by `decideVisually`. * `confidence` (default: `0.7`) - minimal probability, between 0 and 1, for a statement to pass. * `timeout` (default: `15000`) - request timeout in ms. * `maxLength` (default: `12000`) - maximal length of ARIA snapshot or HTML sent to the model. @@ -95,9 +96,9 @@ const probability = await I.decide('cart is empty'); #### Parameters -* `statements` **([string][4] | [Array][5]<[string][4]>)** statement or list of statements to verify. +* `statements` **([string][5] | [Array][6]<[string][5]>)** statement or list of statements to verify. -Returns **[Promise][6]<([number][7] | [Array][5]<[number][7]> | [undefined][8])>** probability of each statement, `undefined` in `skip` mode. +Returns **[Promise][7]<([number][8] | [Array][6]<[number][8]> | [undefined][9])>** probability of each statement, `undefined` in `skip` mode. ### decideVisually @@ -112,9 +113,9 @@ I.decideVisually(['logo is in the header', 'page uses dark theme']); #### Parameters -* `statements` **([string][4] | [Array][5]<[string][4]>)** statement or list of statements to verify. +* `statements` **([string][5] | [Array][6]<[string][5]>)** statement or list of statements to verify. -Returns **[Promise][6]<([number][7] | [Array][5]<[number][7]> | [undefined][8])>** probability of each statement, `undefined` in `skip` mode. +Returns **[Promise][7]<([number][8] | [Array][6]<[number][8]> | [undefined][9])>** probability of each statement, `undefined` in `skip` mode. [1]: https://openrouter.ai/typesafe/jev-1.13 @@ -122,12 +123,14 @@ Returns **[Promise][6]<([number][7] | [Array][5]<[number][7]> | [undefined][8])> [3]: https://openrouter.ai/models?output_modalities=decisions -[4]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String +[4]: https://ai-sdk.dev/docs/ai-sdk-core/decisions + +[5]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String -[5]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array +[6]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array -[6]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise +[7]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise -[7]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number +[8]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number -[8]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/undefined +[9]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/undefined diff --git a/lib/ai.js b/lib/ai.js index 7f76c0ae3..7fa9cd0ca 100644 --- a/lib/ai.js +++ b/lib/ai.js @@ -5,7 +5,6 @@ import event from './event.js' import { removeNonInteractiveElements, minifyHtml, splitByChunks } from './html.js' import { generateText, experimental_decide as decide, APICallError } from 'ai' import { createOpenRouter } from '@openrouter/ai-sdk-provider' -import { createTypeSafeAi } from '@ai-sdk/typesafe-ai' import { fileURLToPath } from 'url' import path from 'path' import { fileExists, resolveImportModulePath } from './utils.js' @@ -273,13 +272,7 @@ function parseCodeBlocks(response) { return modifiedSnippets.filter(snippet => !!snippet) } -const DECISION_PROVIDERS = { - openrouter: { keyName: 'OPENROUTER_API_KEY' }, - typesafe: { keyName: 'TYPESAFE_API_KEY' }, -} - const defaultDecisionConfig = { - provider: 'openrouter', model: 'typesafe/jev-1.13', visualModel: 'cloudflare/clef', confidence: 0.7, @@ -293,23 +286,19 @@ class DecisionAI { constructor(config = {}) { this.config = { ...defaultDecisionConfig, ...config } - const { provider, confidence } = this.config - this.provider = DECISION_PROVIDERS[provider] - if (!this.provider) throw new Error(`Unknown decision provider "${provider}" in ai.decisionModel, use one of: ${Object.keys(DECISION_PROVIDERS).join(', ')}`) + const { confidence } = this.config if (!(confidence > 0 && confidence < 1)) throw new Error(`ai.decisionModel.confidence must be between 0 and 1, got ${confidence}`) this.fetchImpl = fetch } checkModel() { - if (this.config.apiKey || process.env[this.provider.keyName]) return + if (this.config.apiKey || process.env.OPENROUTER_API_KEY) return const noKeyErrorMessage = ` No API key is set for decision model. - [!] Set ${this.provider.keyName} environment variable or apiKey in ai.decisionModel config. - - Example (connect to OpenRouter, default): + [!] Set OPENROUTER_API_KEY environment variable or apiKey in ai.decisionModel config. export OPENROUTER_API_KEY=sk-or-... @@ -322,17 +311,6 @@ class DecisionAI { Get a key at https://openrouter.ai/settings/keys - Example (connect to TypeSafe): - - export TYPESAFE_API_KEY=... - - ai: { - decisionModel: { - provider: 'typesafe', - model: 'jev-latest', - } - } - See https://openrouter.ai/models?output_modalities=decisions for all decision models. `.trim() @@ -372,7 +350,7 @@ class DecisionAI { decisionModel(model) { const settings = { - apiKey: this.config.apiKey || process.env[this.provider.keyName], + apiKey: this.config.apiKey || process.env.OPENROUTER_API_KEY, fetch: async (url, options) => { try { return await this.fetchImpl(url, options) @@ -382,7 +360,6 @@ class DecisionAI { } }, } - if (this.config.provider === 'typesafe') return createTypeSafeAi(settings).decisionModel(model) return createOpenRouter(settings).evaluationModel(model) } } diff --git a/lib/helper/Decision.js b/lib/helper/Decision.js index e67ac2f90..248e8393a 100644 --- a/lib/helper/Decision.js +++ b/lib/helper/Decision.js @@ -43,7 +43,6 @@ import { DecisionAI, DecisionConnectionError } from '../ai.js' * ```js * ai: { * decisionModel: { - * provider: 'openrouter', * model: 'typesafe/jev-1.13', * visualModel: 'cloudflare/clef', * confidence: 0.7, @@ -55,10 +54,12 @@ import { DecisionAI, DecisionConnectionError } from '../ai.js' * } * ``` * - * * `provider` (default: `openrouter`) - decision API to call: `openrouter` (reads `OPENROUTER_API_KEY`) or `typesafe` (reads `TYPESAFE_API_KEY`). - * * `apiKey` (optional) - API key, overrides the environment variable. - * * `model` (default: `typesafe/jev-1.13`) - decision model used by `decide`. Use `jev-latest` with the `typesafe` provider. - * * `visualModel` (default: `cloudflare/clef`) - decision model with image input used by `decideVisually`. Available on OpenRouter only. + * Decisions are requested through the [OpenRouter Decisions API](https://openrouter.ai/models?output_modalities=decisions) + * with AI SDK [`experimental_decide`](https://ai-sdk.dev/docs/ai-sdk-core/decisions). Set `OPENROUTER_API_KEY` to use it. + * + * * `apiKey` (optional) - OpenRouter API key, overrides `OPENROUTER_API_KEY` environment variable. + * * `model` (default: `typesafe/jev-1.13`) - decision model used by `decide`. + * * `visualModel` (default: `cloudflare/clef`) - decision model with image input used by `decideVisually`. * * `confidence` (default: `0.7`) - minimal probability, between 0 and 1, for a statement to pass. * * `timeout` (default: `15000`) - request timeout in ms. * * `maxLength` (default: `12000`) - maximal length of ARIA snapshot or HTML sent to the model. diff --git a/package.json b/package.json index bbec2bcca..60d3719c6 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,6 @@ "publish-beta": "./runok.cjs publish:next-beta-version" }, "dependencies": { - "@ai-sdk/typesafe-ai": "^3.0.15", "@codeceptjs/configure": "^4.0.0-beta.4", "@codeceptjs/helper": "2.0.4", "@cucumber/cucumber-expressions": "18", diff --git a/test/unit/ai_test.js b/test/unit/ai_test.js index 318271404..b8826dd3d 100644 --- a/test/unit/ai_test.js +++ b/test/unit/ai_test.js @@ -300,24 +300,20 @@ describe('AI module with mock provider', () => { describe('DecisionAI', () => { it('guides user to set API key', async () => { - const key = process.env.TYPESAFE_API_KEY - delete process.env.TYPESAFE_API_KEY + const key = process.env.OPENROUTER_API_KEY + delete process.env.OPENROUTER_API_KEY try { - const decisionAI = new DecisionAI({ provider: 'typesafe' }) - expect(() => decisionAI.checkModel()).to.throw(/TYPESAFE_API_KEY[\s\S]*openrouter\.ai\/settings\/keys/) - const err = await decisionAI.decide('jev-latest', 'state', ['page is loaded']).catch(e => e) + const decisionAI = new DecisionAI() + expect(() => decisionAI.checkModel()).to.throw(/OPENROUTER_API_KEY[\s\S]*openrouter\.ai\/settings\/keys/) + const err = await decisionAI.decide('typesafe/jev-1.13', 'state', ['page is loaded']).catch(e => e) expect(err.message).to.include('No API key is set for decision model') } finally { - if (key) process.env.TYPESAFE_API_KEY = key + if (key) process.env.OPENROUTER_API_KEY = key } }) it('accepts API key from config', () => { - expect(() => new DecisionAI({ provider: 'typesafe', apiKey: 'secret' }).checkModel()).not.to.throw() - }) - - it('rejects unknown provider', () => { - expect(() => new DecisionAI({ provider: 'unknown' })).to.throw('Unknown decision provider') + expect(() => new DecisionAI({ apiKey: 'secret' }).checkModel()).not.to.throw() }) it('asks all statements in one request', async () => { diff --git a/test/unit/helper/Decision_test.js b/test/unit/helper/Decision_test.js index 5db95425b..8a5359b98 100644 --- a/test/unit/helper/Decision_test.js +++ b/test/unit/helper/Decision_test.js @@ -123,16 +123,6 @@ describe('Decision helper', () => { expect(calls[0].body.state.html).to.include('

Checkout

') }) - it('uses typesafe endpoint', async () => { - decision = createDecision({ apiKey: 'secret', provider: 'typesafe', model: 'jev-latest' }) - decision._actingHelper = () => browser - decision.decisionAI.fetchImpl = fakeFetch(noul(0.9), calls) - await decision.decide('page is loaded') - - expect(calls[0].url).to.equal('https://api.typesafe.ai/v1/systemone') - expect(calls[0].body.model).to.equal('jev-latest') - }) - it('reports http errors', async () => { decision.decisionAI.fetchImpl = fakeFetch({}, calls, 402) const err = await decision.decide('page is loaded').catch(e => e) @@ -204,23 +194,22 @@ describe('Decision helper', () => { }) it('validates config', () => { - expect(() => createDecision({ apiKey: 'secret', provider: 'unknown' })).to.throw('Unknown decision provider') expect(() => createDecision({ apiKey: 'secret', confidence: 1.5 })).to.throw('between 0 and 1') }) it('requires api key only when deciding', async () => { - const key = process.env.TYPESAFE_API_KEY - delete process.env.TYPESAFE_API_KEY + const key = process.env.OPENROUTER_API_KEY + delete process.env.OPENROUTER_API_KEY try { - decision = createDecision({ provider: 'typesafe' }) + decision = createDecision({}) decision._actingHelper = () => browser decision.decisionAI.fetchImpl = fakeFetch(noul(0.9), calls) const err = await decision.decide('page is loaded').catch(e => e) - expect(err.message).to.include('TYPESAFE_API_KEY') + expect(err.message).to.include('OPENROUTER_API_KEY') expect(calls).to.be.empty } finally { - if (key) process.env.TYPESAFE_API_KEY = key + if (key) process.env.OPENROUTER_API_KEY = key } }) From a0a5bd1acdff406a354999ff00c7ac74f7110e63 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Thu, 8 Oct 2026 03:25:43 +0300 Subject: [PATCH 3/5] feat(Decision): accept AI SDK decision models in config ai.decisionModel.model and visualModel accept an OpenRouter model ID or an AI SDK decision model, e.g. createOpenRouter().evaluationModel(id). Document configuring decision models in ai.md; drop custom decisions section. Co-Authored-By: Claude Opus 5.5 --- docs/ai.md | 71 +++++++++++++++++-------------- docs/assertions.md | 6 +-- docs/helpers/Decision.md | 22 ++++++++-- lib/ai.js | 29 +++++++++---- lib/helper/Decision.js | 21 +++++++-- test/unit/ai_test.js | 4 +- test/unit/helper/Decision_test.js | 17 ++++++++ 7 files changed, 118 insertions(+), 52 deletions(-) diff --git a/docs/ai.md b/docs/ai.md index 9a483c0e9..4721f3e73 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -353,49 +353,56 @@ I.decideVisually('sidebar is shown') It uses a [decision model](https://openrouter.ai/models?output_modalities=decisions) like [Jev](https://openrouter.ai/typesafe/jev-1.13) instead of a chat model. A decision model reads the page and returns the probability that a statement is true. It is fast, costs a fraction of a cent per request, and gives a probability instead of free text, so a step passes or fails on a confidence threshold you set. -Decisions are requested through the [OpenRouter Decisions API](https://openrouter.ai/models?output_modalities=decisions) with AI SDK [`experimental_decide`](https://ai-sdk.dev/docs/ai-sdk-core/decisions). Set `OPENROUTER_API_KEY` and configure the model in `ai.decisionModel`. Decisions don't need `ai.model` or the `--ai` flag. See [Decision Assertions](/assertions#decision-assertions) for setup and usage. +Decision models are separate from `ai.model`: decisions don't need `ai.model` or the `--ai` flag. CodeceptJS calls them with AI SDK [`experimental_decide`](https://ai-sdk.dev/docs/ai-sdk-core/decisions). -### Custom Decisions +### Configure Decision Models -`I.decide` asks yes/no questions. AI SDK decisions also support `choice` and `score` questions. Use [`experimental_decide`](https://ai-sdk.dev/docs/ai-sdk-core/decisions) in a [custom helper](/helpers) to ask them about the current page: +Decision models are served by the [OpenRouter Decisions API](https://openrouter.ai/models?output_modalities=decisions). The OpenRouter provider is installed with CodeceptJS, so the quickest setup is a model ID and an API key: + +```bash +export OPENROUTER_API_KEY=sk-or-... +``` + +```js +export default { + ai: { + decisionModel: { + model: 'typesafe/jev-1.13', + visualModel: 'cloudflare/clef', + }, + }, + helpers: { + Playwright: { url: 'http://localhost' }, + Decision: {}, + }, +} +``` + +To configure the provider yourself, import `createOpenRouter` from `@openrouter/ai-sdk-provider` and pass AI SDK decision models with `evaluationModel()`: ```js -import Helper from '@codeceptjs/helper' -import { experimental_decide as decide } from 'ai' import { createOpenRouter } from '@openrouter/ai-sdk-provider' -class PageDecisions extends Helper { - async grabPageKind() { - const { Playwright } = this.helpers - const openrouter = createOpenRouter() +const openrouter = createOpenRouter({ + apiKey: process.env.OPENROUTER_API_KEY, +}) - const { answers } = await decide({ +export default { + ai: { + decisionModel: { model: openrouter.evaluationModel('typesafe/jev-1.13'), - state: { - url: await Playwright.grabCurrentUrl(), - aria: await Playwright.grabAriaSnapshot(), - }, - questions: { - page: { - type: 'choice', - instructions: 'What kind of page is shown?', - criteria: { login: 'sign in form', dashboard: 'user dashboard', error: 'error message' }, - }, - }, - }) - - return answers.page.choice - } + visualModel: openrouter.evaluationModel('cloudflare/clef'), + confidence: 0.8, + }, + }, + helpers: { + Playwright: { url: 'http://localhost' }, + Decision: {}, + }, } - -export default PageDecisions -``` - -```js -const kind = await I.grabPageKind() ``` -`ai` and `@openrouter/ai-sdk-provider` are installed with CodeceptJS. A `score` question returns a fractional position on a list of levels, and a `boolean` question returns a probability. See [AI SDK decisions](https://ai-sdk.dev/docs/ai-sdk-core/decisions) for all question types. +`model` is used by `I.decide`, and `visualModel` by `I.decideVisually`, so `visualModel` needs a model with image input. Both accept an OpenRouter model ID or an AI SDK decision model. See [Decision Assertions](/assertions#decision-assertions) for all options and usage. ## Advanced Configuration diff --git a/docs/assertions.md b/docs/assertions.md index ac5c87e16..088d35f7f 100644 --- a/docs/assertions.md +++ b/docs/assertions.md @@ -426,9 +426,9 @@ helpers: { | Option | Default | Description | |---|---|---| -| `apiKey` | | OpenRouter API key, overrides `OPENROUTER_API_KEY` | -| `model` | `typesafe/jev-1.13` | model for `I.decide` | -| `visualModel` | `cloudflare/clef` | model with image input for `I.decideVisually` | +| `apiKey` | | OpenRouter API key for model IDs, overrides `OPENROUTER_API_KEY` | +| `model` | `typesafe/jev-1.13` | model for `I.decide`: an OpenRouter model ID or an [AI SDK decision model](/ai#configure-decision-models) | +| `visualModel` | `cloudflare/clef` | model with image input for `I.decideVisually`: an OpenRouter model ID or an AI SDK decision model | | `confidence` | `0.7` | minimal probability for a statement to pass | | `timeout` | `15000` | request timeout in ms | | `maxLength` | `12000` | maximal length of ARIA snapshot or HTML sent to the model | diff --git a/docs/helpers/Decision.md b/docs/helpers/Decision.md index 2fbdbfba8..c0ac8a448 100644 --- a/docs/helpers/Decision.md +++ b/docs/helpers/Decision.md @@ -55,9 +55,25 @@ helpers: { Decisions are requested through the [OpenRouter Decisions API][3] with AI SDK [`experimental_decide`][4]. Set `OPENROUTER_API_KEY` to use it. -* `apiKey` (optional) - OpenRouter API key, overrides `OPENROUTER_API_KEY` environment variable. -* `model` (default: `typesafe/jev-1.13`) - decision model used by `decide`. -* `visualModel` (default: `cloudflare/clef`) - decision model with image input used by `decideVisually`. +* `apiKey` (optional) - OpenRouter API key for model IDs, overrides `OPENROUTER_API_KEY` environment variable. +* `model` (default: `typesafe/jev-1.13`) - decision model used by `decide`: OpenRouter model ID or AI SDK decision model. +* `visualModel` (default: `cloudflare/clef`) - decision model with image input used by `decideVisually`: OpenRouter model ID or AI SDK decision model. + +AI SDK decision models are created with `@openrouter/ai-sdk-provider`: + +```js +import { createOpenRouter } from '@openrouter/ai-sdk-provider' + +const openrouter = createOpenRouter({ apiKey: process.env.OPENROUTER_API_KEY }) + +ai: { + decisionModel: { + model: openrouter.evaluationModel('typesafe/jev-1.13'), + visualModel: openrouter.evaluationModel('cloudflare/clef'), + }, +}, +``` + * `confidence` (default: `0.7`) - minimal probability, between 0 and 1, for a statement to pass. * `timeout` (default: `15000`) - request timeout in ms. * `maxLength` (default: `12000`) - maximal length of ARIA snapshot or HTML sent to the model. diff --git a/lib/ai.js b/lib/ai.js index 7fa9cd0ca..c6eb74fbc 100644 --- a/lib/ai.js +++ b/lib/ai.js @@ -292,7 +292,8 @@ class DecisionAI { this.fetchImpl = fetch } - checkModel() { + checkModel(model) { + if (typeof model !== 'string') return if (this.config.apiKey || process.env.OPENROUTER_API_KEY) return const noKeyErrorMessage = ` @@ -302,15 +303,20 @@ class DecisionAI { export OPENROUTER_API_KEY=sk-or-... + Get a key at https://openrouter.ai/settings/keys + + Or pass AI SDK decision model in ai.decisionModel config: + + import { createOpenRouter } from '@openrouter/ai-sdk-provider' + + const openrouter = createOpenRouter({ apiKey: '...' }) + ai: { decisionModel: { - model: 'typesafe/jev-1.13', - confidence: 0.7, + model: openrouter.evaluationModel('typesafe/jev-1.13'), } } - Get a key at https://openrouter.ai/settings/keys - See https://openrouter.ai/models?output_modalities=decisions for all decision models. `.trim() @@ -318,10 +324,13 @@ class DecisionAI { } async decide(model, state, statements) { - this.checkModel() + this.checkModel(model) + + let modelId = model + if (typeof model !== 'string') modelId = model.modelId const questions = Object.fromEntries(statements.map((statement, i) => [`q${i}`, { type: 'boolean', instructions: statement }])) - debug('Decision request', model, statements) + debug('Decision request', modelId, statements) const controller = new AbortController() const timer = setTimeout(() => controller.abort(), this.config.timeout) @@ -336,8 +345,9 @@ class DecisionAI { abortSignal: controller.signal, }) } catch (err) { - if (controller.signal.aborted) throw new DecisionConnectionError(`Decision model ${model} did not respond in ${this.config.timeout}ms`) - if (APICallError.isInstance(err)) throw new Error(`Decision model ${model} responded with ${err.statusCode}: ${err.responseBody || err.message}`) + if (controller.signal.aborted) throw new DecisionConnectionError(`Decision model ${modelId} did not respond in ${this.config.timeout}ms`) + if (APICallError.isInstance(err) && !err.statusCode) throw new DecisionConnectionError(`Decision model ${modelId} request failed: ${err.message}`) + if (APICallError.isInstance(err)) throw new Error(`Decision model ${modelId} responded with ${err.statusCode}: ${err.responseBody || err.message}`) throw err } finally { clearTimeout(timer) @@ -349,6 +359,7 @@ class DecisionAI { } decisionModel(model) { + if (typeof model !== 'string') return model const settings = { apiKey: this.config.apiKey || process.env.OPENROUTER_API_KEY, fetch: async (url, options) => { diff --git a/lib/helper/Decision.js b/lib/helper/Decision.js index 248e8393a..43ea893a7 100644 --- a/lib/helper/Decision.js +++ b/lib/helper/Decision.js @@ -57,9 +57,24 @@ import { DecisionAI, DecisionConnectionError } from '../ai.js' * Decisions are requested through the [OpenRouter Decisions API](https://openrouter.ai/models?output_modalities=decisions) * with AI SDK [`experimental_decide`](https://ai-sdk.dev/docs/ai-sdk-core/decisions). Set `OPENROUTER_API_KEY` to use it. * - * * `apiKey` (optional) - OpenRouter API key, overrides `OPENROUTER_API_KEY` environment variable. - * * `model` (default: `typesafe/jev-1.13`) - decision model used by `decide`. - * * `visualModel` (default: `cloudflare/clef`) - decision model with image input used by `decideVisually`. + * * `apiKey` (optional) - OpenRouter API key for model IDs, overrides `OPENROUTER_API_KEY` environment variable. + * * `model` (default: `typesafe/jev-1.13`) - decision model used by `decide`: OpenRouter model ID or AI SDK decision model. + * * `visualModel` (default: `cloudflare/clef`) - decision model with image input used by `decideVisually`: OpenRouter model ID or AI SDK decision model. + * + * AI SDK decision models are created with `@openrouter/ai-sdk-provider`: + * + * ```js + * import { createOpenRouter } from '@openrouter/ai-sdk-provider' + * + * const openrouter = createOpenRouter({ apiKey: process.env.OPENROUTER_API_KEY }) + * + * ai: { + * decisionModel: { + * model: openrouter.evaluationModel('typesafe/jev-1.13'), + * visualModel: openrouter.evaluationModel('cloudflare/clef'), + * }, + * }, + * ``` * * `confidence` (default: `0.7`) - minimal probability, between 0 and 1, for a statement to pass. * * `timeout` (default: `15000`) - request timeout in ms. * * `maxLength` (default: `12000`) - maximal length of ARIA snapshot or HTML sent to the model. diff --git a/test/unit/ai_test.js b/test/unit/ai_test.js index b8826dd3d..877b0638f 100644 --- a/test/unit/ai_test.js +++ b/test/unit/ai_test.js @@ -304,7 +304,7 @@ describe('DecisionAI', () => { delete process.env.OPENROUTER_API_KEY try { const decisionAI = new DecisionAI() - expect(() => decisionAI.checkModel()).to.throw(/OPENROUTER_API_KEY[\s\S]*openrouter\.ai\/settings\/keys/) + expect(() => decisionAI.checkModel('typesafe/jev-1.13')).to.throw(/OPENROUTER_API_KEY[\s\S]*openrouter\.ai\/settings\/keys/) const err = await decisionAI.decide('typesafe/jev-1.13', 'state', ['page is loaded']).catch(e => e) expect(err.message).to.include('No API key is set for decision model') } finally { @@ -313,7 +313,7 @@ describe('DecisionAI', () => { }) it('accepts API key from config', () => { - expect(() => new DecisionAI({ apiKey: 'secret' }).checkModel()).not.to.throw() + expect(() => new DecisionAI({ apiKey: 'secret' }).checkModel('typesafe/jev-1.13')).not.to.throw() }) it('asks all statements in one request', async () => { diff --git a/test/unit/helper/Decision_test.js b/test/unit/helper/Decision_test.js index 8a5359b98..8e7751d53 100644 --- a/test/unit/helper/Decision_test.js +++ b/test/unit/helper/Decision_test.js @@ -5,6 +5,7 @@ import path from 'path' import Decision from '../../../lib/helper/Decision.js' import store from '../../../lib/store.js' import Config from '../../../lib/config.js' +import { createOpenRouter } from '@openrouter/ai-sdk-provider' function createDecision(decisionModel, config = {}) { Config.create({ ai: { decisionModel } }) @@ -123,6 +124,22 @@ describe('Decision helper', () => { expect(calls[0].body.state.html).to.include('

Checkout

') }) + it('uses AI SDK decision model from config', async () => { + const key = process.env.OPENROUTER_API_KEY + delete process.env.OPENROUTER_API_KEY + try { + const openrouter = createOpenRouter({ apiKey: 'config-key', fetch: fakeFetch(noul(0.9), calls) }) + decision = createDecision({ model: openrouter.evaluationModel('typesafe/jev-latest') }) + decision._actingHelper = () => browser + + expect(await decision.decide('page is loaded')).to.equal(0.9) + expect(calls[0].body.model).to.equal('typesafe/jev-latest') + expect(calls[0].headers.get('authorization')).to.equal('Bearer config-key') + } finally { + if (key) process.env.OPENROUTER_API_KEY = key + } + }) + it('reports http errors', async () => { decision.decisionAI.fetchImpl = fakeFetch({}, calls, 402) const err = await decision.decide('page is loaded').catch(e => e) From 572c1e8f6811847c1e62b8aa5d517d362698971c Mon Sep 17 00:00:00 2001 From: DavertMik Date: Thu, 8 Oct 2026 03:35:22 +0300 Subject: [PATCH 4/5] feat(Decision): configure decision models only via OpenRouter provider ai.decisionModel.model and visualModel must be AI SDK decision models created with createOpenRouter().evaluationModel(). Drop model ID strings, default models and apiKey option. @openrouter/ai-sdk-provider becomes a dev dependency; users install it like other AI SDK providers. Co-Authored-By: Claude Opus 5.5 --- docs/agents.md | 2 +- docs/ai.md | 39 ++++------ docs/assertions.md | 17 ++--- docs/helpers/Decision.md | 76 +++++++++---------- lib/ai.js | 63 +++++----------- lib/helper/Decision.js | 63 ++++++++-------- package.json | 2 +- test/unit/ai_test.js | 36 ++++----- test/unit/helper/Decision_test.js | 118 +++++++++++++++--------------- 9 files changed, 178 insertions(+), 238 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index 0047e9ff4..a216c0b75 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -186,7 +186,7 @@ I.decide([ The agent runs the statement on the live page like any other command and keeps it only if it passes. The test then checks it on every run. -To let the agent use decisions, enable the Decision helper and set `OPENROUTER_API_KEY`. See [Decision Assertions](/assertions#decision-assertions) for the config. +To let the agent use decisions, [configure a decision model](/ai#configure-decision-models) and enable the Decision helper. Decision models like [Jev](https://openrouter.ai/typesafe/jev-1.13) are built for this. They answer in a fraction of a second, cost a fraction of a cent per request, and return a probability instead of free text. That makes them fast and cheap enough to run on every CI build, and predictable enough to keep in a test. diff --git a/docs/ai.md b/docs/ai.md index 4721f3e73..140685869 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -357,28 +357,7 @@ Decision models are separate from `ai.model`: decisions don't need `ai.model` or ### Configure Decision Models -Decision models are served by the [OpenRouter Decisions API](https://openrouter.ai/models?output_modalities=decisions). The OpenRouter provider is installed with CodeceptJS, so the quickest setup is a model ID and an API key: - -```bash -export OPENROUTER_API_KEY=sk-or-... -``` - -```js -export default { - ai: { - decisionModel: { - model: 'typesafe/jev-1.13', - visualModel: 'cloudflare/clef', - }, - }, - helpers: { - Playwright: { url: 'http://localhost' }, - Decision: {}, - }, -} -``` - -To configure the provider yourself, import `createOpenRouter` from `@openrouter/ai-sdk-provider` and pass AI SDK decision models with `evaluationModel()`: +Decision models are served by the [OpenRouter Decisions API](https://openrouter.ai/models?output_modalities=decisions). Install the [OpenRouter provider](https://ai-sdk.dev/providers/community-providers/openrouter) `@openrouter/ai-sdk-provider`, set `OPENROUTER_API_KEY`, and create the decision model with `evaluationModel()` in `codecept.conf.js`: ```js import { createOpenRouter } from '@openrouter/ai-sdk-provider' @@ -391,7 +370,6 @@ export default { ai: { decisionModel: { model: openrouter.evaluationModel('typesafe/jev-1.13'), - visualModel: openrouter.evaluationModel('cloudflare/clef'), confidence: 0.8, }, }, @@ -402,7 +380,20 @@ export default { } ``` -`model` is used by `I.decide`, and `visualModel` by `I.decideVisually`, so `visualModel` needs a model with image input. Both accept an OpenRouter model ID or an AI SDK decision model. See [Decision Assertions](/assertions#decision-assertions) for all options and usage. +`model` is used by `I.decide`. + +### Visual Decision Model + +`I.decideVisually` sends a screenshot, so it needs a separate decision model with image input. Set it as `visualModel`: + +```js +decisionModel: { + model: openrouter.evaluationModel('typesafe/jev-1.13'), + visualModel: openrouter.evaluationModel('cloudflare/clef'), +}, +``` + +See [Decision Assertions](/assertions#decision-assertions) for all options and usage. ## Advanced Configuration diff --git a/docs/assertions.md b/docs/assertions.md index 088d35f7f..5707fb09d 100644 --- a/docs/assertions.md +++ b/docs/assertions.md @@ -407,15 +407,9 @@ A [decision model](https://openrouter.ai/models?output_modalities=decisions), li - **Cost-efficient.** A request costs a fraction of a cent. You can run decision assertions in every CI build. - **Reliable.** The answer is a probability, not free text. There is nothing to parse, and you choose how confident the model must be for the step to pass. -Set `OPENROUTER_API_KEY`, configure the decision model in the `ai` section, and enable the helper next to your browser helper: +Configure the decision model in `ai.decisionModel` as described in [Configure Decision Models](/ai#configure-decision-models), and enable the helper next to your browser helper: ```js -ai: { - decisionModel: { - model: 'typesafe/jev-1.13', - confidence: 0.7, - }, -}, helpers: { Playwright: { url: 'http://localhost' }, Decision: {}, @@ -426,14 +420,13 @@ helpers: { | Option | Default | Description | |---|---|---| -| `apiKey` | | OpenRouter API key for model IDs, overrides `OPENROUTER_API_KEY` | -| `model` | `typesafe/jev-1.13` | model for `I.decide`: an OpenRouter model ID or an [AI SDK decision model](/ai#configure-decision-models) | -| `visualModel` | `cloudflare/clef` | model with image input for `I.decideVisually`: an OpenRouter model ID or an AI SDK decision model | +| `model` | | decision model for `I.decide` | +| `visualModel` | | decision model with image input for `I.decideVisually` | | `confidence` | `0.7` | minimal probability for a statement to pass | | `timeout` | `15000` | request timeout in ms | | `maxLength` | `12000` | maximal length of ARIA snapshot or HTML sent to the model | -Any model from the [OpenRouter decision models](https://openrouter.ai/models?output_modalities=decisions) list can be used. Decisions don't need the `--ai` flag, and `ai.model` is not required. +Any model from the [OpenRouter decision models](https://openrouter.ai/models?output_modalities=decisions) list can be used with `openrouter.evaluationModel()`. Decisions don't need the `--ai` flag, and `ai.model` is not required. Then assert statements about the current page: @@ -456,7 +449,7 @@ I.decideVisually('sidebar is shown') expected page to satisfy "success message is shown" (12%) with confidence of 70% ``` -`I.decideVisually` also sends a screenshot, so it needs a model with image input. It uses `visualModel`, which is [Clef](https://openrouter.ai/cloudflare/clef) by default. Visual decisions are experimental. +`I.decideVisually` also sends a screenshot, so it needs a [visual decision model](/ai#visual-decision-model) with image input, like [Clef](https://openrouter.ai/cloudflare/clef), set as `visualModel`. Visual decisions are experimental. To turn decisions off without removing the helper, set its `mode`: diff --git a/docs/helpers/Decision.md b/docs/helpers/Decision.md index c0ac8a448..7d202371c 100644 --- a/docs/helpers/Decision.md +++ b/docs/helpers/Decision.md @@ -36,48 +36,44 @@ This helper must be enabled together with a web helper (Playwright, Puppeteer, W ## Configuration -Decision model is configured in the `ai.decisionModel` section of the config: - -```js -ai: { - decisionModel: { - model: 'typesafe/jev-1.13', - visualModel: 'cloudflare/clef', - confidence: 0.7, - }, -}, -helpers: { - Playwright: { url: 'http://localhost', browser: 'chromium' }, - Decision: {}, -} -``` - -Decisions are requested through the [OpenRouter Decisions API][3] -with AI SDK [`experimental_decide`][4]. Set `OPENROUTER_API_KEY` to use it. - -* `apiKey` (optional) - OpenRouter API key for model IDs, overrides `OPENROUTER_API_KEY` environment variable. -* `model` (default: `typesafe/jev-1.13`) - decision model used by `decide`: OpenRouter model ID or AI SDK decision model. -* `visualModel` (default: `cloudflare/clef`) - decision model with image input used by `decideVisually`: OpenRouter model ID or AI SDK decision model. - -AI SDK decision models are created with `@openrouter/ai-sdk-provider`: +Decision model is created with the OpenRouter provider from `@openrouter/ai-sdk-provider` +and configured in the `ai.decisionModel` section of the config: ```js import { createOpenRouter } from '@openrouter/ai-sdk-provider' -const openrouter = createOpenRouter({ apiKey: process.env.OPENROUTER_API_KEY }) +const openrouter = createOpenRouter({ + apiKey: process.env.OPENROUTER_API_KEY, +}) -ai: { - decisionModel: { - model: openrouter.evaluationModel('typesafe/jev-1.13'), - visualModel: openrouter.evaluationModel('cloudflare/clef'), +export default { + ai: { + decisionModel: { + model: openrouter.evaluationModel('typesafe/jev-1.13'), + confidence: 0.8, + }, }, -}, + helpers: { + Playwright: { url: 'http://localhost' }, + Decision: {}, + }, +} ``` +* `model` - decision model used by `decide`. * `confidence` (default: `0.7`) - minimal probability, between 0 and 1, for a statement to pass. * `timeout` (default: `15000`) - request timeout in ms. * `maxLength` (default: `12000`) - maximal length of ARIA snapshot or HTML sent to the model. +`decideVisually` uses a separate decision model with image input, configured as `visualModel`: + +```js +decisionModel: { + model: openrouter.evaluationModel('typesafe/jev-1.13'), + visualModel: openrouter.evaluationModel('cloudflare/clef'), +}, +``` + The helper has one option: * `mode` (default: `assert`) - how decisions are executed: @@ -112,9 +108,9 @@ const probability = await I.decide('cart is empty'); #### Parameters -* `statements` **([string][5] | [Array][6]<[string][5]>)** statement or list of statements to verify. +* `statements` **([string][4] | [Array][5]<[string][4]>)** statement or list of statements to verify. -Returns **[Promise][7]<([number][8] | [Array][6]<[number][8]> | [undefined][9])>** probability of each statement, `undefined` in `skip` mode. +Returns **[Promise][6]<([number][7] | [Array][5]<[number][7]> | [undefined][8])>** probability of each statement, `undefined` in `skip` mode. ### decideVisually @@ -129,9 +125,9 @@ I.decideVisually(['logo is in the header', 'page uses dark theme']); #### Parameters -* `statements` **([string][5] | [Array][6]<[string][5]>)** statement or list of statements to verify. +* `statements` **([string][4] | [Array][5]<[string][4]>)** statement or list of statements to verify. -Returns **[Promise][7]<([number][8] | [Array][6]<[number][8]> | [undefined][9])>** probability of each statement, `undefined` in `skip` mode. +Returns **[Promise][6]<([number][7] | [Array][5]<[number][7]> | [undefined][8])>** probability of each statement, `undefined` in `skip` mode. [1]: https://openrouter.ai/typesafe/jev-1.13 @@ -139,14 +135,12 @@ Returns **[Promise][7]<([number][8] | [Array][6]<[number][8]> | [undefined][9])> [3]: https://openrouter.ai/models?output_modalities=decisions -[4]: https://ai-sdk.dev/docs/ai-sdk-core/decisions - -[5]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String +[4]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String -[6]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array +[5]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array -[7]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise +[6]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise -[8]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number +[7]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number -[9]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/undefined +[8]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/undefined diff --git a/lib/ai.js b/lib/ai.js index c6eb74fbc..e90b725a7 100644 --- a/lib/ai.js +++ b/lib/ai.js @@ -4,7 +4,6 @@ import output from './output.js' import event from './event.js' import { removeNonInteractiveElements, minifyHtml, splitByChunks } from './html.js' import { generateText, experimental_decide as decide, APICallError } from 'ai' -import { createOpenRouter } from '@openrouter/ai-sdk-provider' import { fileURLToPath } from 'url' import path from 'path' import { fileExists, resolveImportModulePath } from './utils.js' @@ -273,8 +272,6 @@ function parseCodeBlocks(response) { } const defaultDecisionConfig = { - model: 'typesafe/jev-1.13', - visualModel: 'cloudflare/clef', confidence: 0.7, timeout: 15000, maxLength: 12000, @@ -288,49 +285,41 @@ class DecisionAI { const { confidence } = this.config if (!(confidence > 0 && confidence < 1)) throw new Error(`ai.decisionModel.confidence must be between 0 and 1, got ${confidence}`) - - this.fetchImpl = fetch } - checkModel(model) { - if (typeof model !== 'string') return - if (this.config.apiKey || process.env.OPENROUTER_API_KEY) return - - const noKeyErrorMessage = ` - No API key is set for decision model. - - [!] Set OPENROUTER_API_KEY environment variable or apiKey in ai.decisionModel config. + checkModel(option) { + if (this.config[option]) return - export OPENROUTER_API_KEY=sk-or-... - - Get a key at https://openrouter.ai/settings/keys + const noModelErrorMessage = ` + No decision model is set in ai.decisionModel.${option} config. - Or pass AI SDK decision model in ai.decisionModel config: + [!] Configure decision model with OpenRouter provider: import { createOpenRouter } from '@openrouter/ai-sdk-provider' - const openrouter = createOpenRouter({ apiKey: '...' }) + const openrouter = createOpenRouter({ + apiKey: process.env.OPENROUTER_API_KEY, + }) ai: { decisionModel: { model: openrouter.evaluationModel('typesafe/jev-1.13'), + visualModel: openrouter.evaluationModel('cloudflare/clef'), } } See https://openrouter.ai/models?output_modalities=decisions for all decision models. `.trim() - throw new Error(noKeyErrorMessage) + throw new Error(noModelErrorMessage) } - async decide(model, state, statements) { - this.checkModel(model) - - let modelId = model - if (typeof model !== 'string') modelId = model.modelId + async decide(option, state, statements) { + this.checkModel(option) + const model = this.config[option] const questions = Object.fromEntries(statements.map((statement, i) => [`q${i}`, { type: 'boolean', instructions: statement }])) - debug('Decision request', modelId, statements) + debug('Decision request', model.modelId, statements) const controller = new AbortController() const timer = setTimeout(() => controller.abort(), this.config.timeout) @@ -338,16 +327,16 @@ class DecisionAI { let result try { result = await decide({ - model: this.decisionModel(model), + model, state, questions, maxRetries: 0, abortSignal: controller.signal, }) } catch (err) { - if (controller.signal.aborted) throw new DecisionConnectionError(`Decision model ${modelId} did not respond in ${this.config.timeout}ms`) - if (APICallError.isInstance(err) && !err.statusCode) throw new DecisionConnectionError(`Decision model ${modelId} request failed: ${err.message}`) - if (APICallError.isInstance(err)) throw new Error(`Decision model ${modelId} responded with ${err.statusCode}: ${err.responseBody || err.message}`) + if (controller.signal.aborted) throw new DecisionConnectionError(`Decision model ${model.modelId} did not respond in ${this.config.timeout}ms`) + if (APICallError.isInstance(err) && !err.statusCode) throw new DecisionConnectionError(`Decision model ${model.modelId} request failed: ${err.message}`) + if (APICallError.isInstance(err)) throw new Error(`Decision model ${model.modelId} responded with ${err.statusCode}: ${err.responseBody || err.message}`) throw err } finally { clearTimeout(timer) @@ -357,22 +346,6 @@ class DecisionAI { return Object.keys(questions).map(id => result.answers[id].probability) } - - decisionModel(model) { - if (typeof model !== 'string') return model - const settings = { - apiKey: this.config.apiKey || process.env.OPENROUTER_API_KEY, - fetch: async (url, options) => { - try { - return await this.fetchImpl(url, options) - } catch (err) { - if (options.signal?.aborted) throw err - throw new DecisionConnectionError(`Decision model ${model} request failed: ${err.message}`) - } - }, - } - return createOpenRouter(settings).evaluationModel(model) - } } export { DecisionAI, DecisionConnectionError } diff --git a/lib/helper/Decision.js b/lib/helper/Decision.js index 43ea893a7..4bc2bc505 100644 --- a/lib/helper/Decision.js +++ b/lib/helper/Decision.js @@ -38,46 +38,43 @@ import { DecisionAI, DecisionConnectionError } from '../ai.js' * * ## Configuration * - * Decision model is configured in the `ai.decisionModel` section of the config: + * Decision model is created with the OpenRouter provider from `@openrouter/ai-sdk-provider` + * and configured in the `ai.decisionModel` section of the config: * * ```js - * ai: { - * decisionModel: { - * model: 'typesafe/jev-1.13', - * visualModel: 'cloudflare/clef', - * confidence: 0.7, + * import { createOpenRouter } from '@openrouter/ai-sdk-provider' + * + * const openrouter = createOpenRouter({ + * apiKey: process.env.OPENROUTER_API_KEY, + * }) + * + * export default { + * ai: { + * decisionModel: { + * model: openrouter.evaluationModel('typesafe/jev-1.13'), + * confidence: 0.8, + * }, + * }, + * helpers: { + * Playwright: { url: 'http://localhost' }, + * Decision: {}, * }, - * }, - * helpers: { - * Playwright: { url: 'http://localhost', browser: 'chromium' }, - * Decision: {}, * } * ``` * - * Decisions are requested through the [OpenRouter Decisions API](https://openrouter.ai/models?output_modalities=decisions) - * with AI SDK [`experimental_decide`](https://ai-sdk.dev/docs/ai-sdk-core/decisions). Set `OPENROUTER_API_KEY` to use it. - * - * * `apiKey` (optional) - OpenRouter API key for model IDs, overrides `OPENROUTER_API_KEY` environment variable. - * * `model` (default: `typesafe/jev-1.13`) - decision model used by `decide`: OpenRouter model ID or AI SDK decision model. - * * `visualModel` (default: `cloudflare/clef`) - decision model with image input used by `decideVisually`: OpenRouter model ID or AI SDK decision model. + * * `model` - decision model used by `decide`. + * * `confidence` (default: `0.7`) - minimal probability, between 0 and 1, for a statement to pass. + * * `timeout` (default: `15000`) - request timeout in ms. + * * `maxLength` (default: `12000`) - maximal length of ARIA snapshot or HTML sent to the model. * - * AI SDK decision models are created with `@openrouter/ai-sdk-provider`: + * `decideVisually` uses a separate decision model with image input, configured as `visualModel`: * * ```js - * import { createOpenRouter } from '@openrouter/ai-sdk-provider' - * - * const openrouter = createOpenRouter({ apiKey: process.env.OPENROUTER_API_KEY }) - * - * ai: { - * decisionModel: { - * model: openrouter.evaluationModel('typesafe/jev-1.13'), - * visualModel: openrouter.evaluationModel('cloudflare/clef'), - * }, + * decisionModel: { + * model: openrouter.evaluationModel('typesafe/jev-1.13'), + * visualModel: openrouter.evaluationModel('cloudflare/clef'), * }, * ``` - * * `confidence` (default: `0.7`) - minimal probability, between 0 and 1, for a statement to pass. - * * `timeout` (default: `15000`) - request timeout in ms. - * * `maxLength` (default: `12000`) - maximal length of ARIA snapshot or HTML sent to the model. * * The helper has one option: * @@ -135,7 +132,7 @@ class Decision extends Helper { if (this.isSkipping) return this._skip() return this._run(async () => { const state = await this._grabState() - return this._assert(statements, this.options.model, state) + return this._assert(statements, 'model', state) }) } @@ -161,7 +158,7 @@ class Decision extends Helper { { type: 'text', text: JSON.stringify(state) }, { type: 'image_url', image_url: { url: `data:image/png;base64,${screenshot}` } }, ] - return this._assert(statements, this.options.visualModel, content) + return this._assert(statements, 'visualModel', content) }) } @@ -183,11 +180,11 @@ class Decision extends Helper { } } - async _assert(statements, model, state) { + async _assert(statements, option, state) { const list = [statements].flat() if (!list.length) throw new Error('No statements to decide') - const probabilities = await this.decisionAI.decide(model, state, list) + const probabilities = await this.decisionAI.decide(option, state, list) const failed = [] const results = list.map((statement, i) => { diff --git a/package.json b/package.json index 60d3719c6..5fb795b94 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,6 @@ "@cucumber/gherkin": "38.0.0", "@cucumber/messages": "32.3.1", "@modelcontextprotocol/sdk": "^1.26.0", - "@openrouter/ai-sdk-provider": "^3.1.0", "@xmldom/xmldom": "0.9.10", "acorn": "8.15.0", "ai": "^7.0.131", @@ -150,6 +149,7 @@ "@eslint/js": "9.39.2", "@faker-js/faker": "10.3.0", "@inquirer/testing": "^3.0.3", + "@openrouter/ai-sdk-provider": "^3.1.0", "@pollyjs/adapter-puppeteer": "6.0.6", "@pollyjs/core": "6.0.6", "@testomatio/reporter": "^2.3.1", diff --git a/test/unit/ai_test.js b/test/unit/ai_test.js index 877b0638f..b70d7bbfd 100644 --- a/test/unit/ai_test.js +++ b/test/unit/ai_test.js @@ -1,5 +1,6 @@ import { expect } from 'chai' import AiAssistant, { DecisionAI } from '../../lib/ai.js' +import { createOpenRouter } from '@openrouter/ai-sdk-provider' import config from '../../lib/config.js' import { createMockModel, MockResponses } from '../support/mock-ai-provider.js' import fs from 'fs' @@ -299,32 +300,25 @@ describe('AI module with mock provider', () => { }) describe('DecisionAI', () => { - it('guides user to set API key', async () => { - const key = process.env.OPENROUTER_API_KEY - delete process.env.OPENROUTER_API_KEY - try { - const decisionAI = new DecisionAI() - expect(() => decisionAI.checkModel('typesafe/jev-1.13')).to.throw(/OPENROUTER_API_KEY[\s\S]*openrouter\.ai\/settings\/keys/) - const err = await decisionAI.decide('typesafe/jev-1.13', 'state', ['page is loaded']).catch(e => e) - expect(err.message).to.include('No API key is set for decision model') - } finally { - if (key) process.env.OPENROUTER_API_KEY = key - } - }) - - it('accepts API key from config', () => { - expect(() => new DecisionAI({ apiKey: 'secret' }).checkModel('typesafe/jev-1.13')).not.to.throw() + it('guides user to configure decision model', async () => { + const decisionAI = new DecisionAI() + expect(() => decisionAI.checkModel('model')).to.throw(/ai\.decisionModel\.model[\s\S]*@openrouter\/ai-sdk-provider/) + const err = await decisionAI.decide('model', 'state', ['page is loaded']).catch(e => e) + expect(err.message).to.include('No decision model is set') }) it('asks all statements in one request', async () => { const calls = [] - const decisionAI = new DecisionAI({ apiKey: 'secret' }) - decisionAI.fetchImpl = async (url, options) => { - calls.push({ url, body: JSON.parse(options.body) }) - return Response.json({ answers: { q0: { type: 'noul', noul: 0.9 }, q1: { type: 'noul', noul: 0.2 } } }) - } + const openrouter = createOpenRouter({ + apiKey: 'secret', + fetch: async (url, options) => { + calls.push({ url, body: JSON.parse(options.body) }) + return Response.json({ answers: { q0: { type: 'noul', noul: 0.9 }, q1: { type: 'noul', noul: 0.2 } } }) + }, + }) + const decisionAI = new DecisionAI({ model: openrouter.evaluationModel('typesafe/jev-1.13') }) - const probabilities = await decisionAI.decide('typesafe/jev-1.13', { url: 'http://localhost' }, ['form is shown', 'cart is empty']) + const probabilities = await decisionAI.decide('model', { url: 'http://localhost' }, ['form is shown', 'cart is empty']) expect(probabilities).to.eql([0.9, 0.2]) expect(calls).to.have.length(1) diff --git a/test/unit/helper/Decision_test.js b/test/unit/helper/Decision_test.js index 8e7751d53..0d31d14da 100644 --- a/test/unit/helper/Decision_test.js +++ b/test/unit/helper/Decision_test.js @@ -7,8 +7,15 @@ import store from '../../../lib/store.js' import Config from '../../../lib/config.js' import { createOpenRouter } from '@openrouter/ai-sdk-provider' -function createDecision(decisionModel, config = {}) { - Config.create({ ai: { decisionModel } }) +let fetchImpl +const openrouter = createOpenRouter({ apiKey: 'secret', fetch: (...args) => fetchImpl(...args) }) +const decisionModels = { + model: openrouter.evaluationModel('typesafe/jev-1.13'), + visualModel: openrouter.evaluationModel('cloudflare/clef'), +} + +function createDecision(decisionModel = {}, config = {}) { + Config.create({ ai: { decisionModel: { ...decisionModels, ...decisionModel } } }) return new Decision(config) } @@ -39,12 +46,12 @@ describe('Decision helper', () => { beforeEach(() => { calls = [] - decision = createDecision({ apiKey: 'secret' }) + decision = createDecision() decision._actingHelper = () => browser }) it('passes when probability reaches confidence', async () => { - decision.decisionAI.fetchImpl = fakeFetch(noul(0.9), calls) + fetchImpl = fakeFetch(noul(0.9), calls) const probability = await decision.decide('submit button is present') expect(probability).to.equal(0.9) @@ -59,7 +66,7 @@ describe('Decision helper', () => { }) it('fails when probability is below confidence', async () => { - decision.decisionAI.fetchImpl = fakeFetch(noul(0.4), calls) + fetchImpl = fakeFetch(noul(0.4), calls) const err = await decision.decide('cart is empty').catch(e => e) expect(err).to.be.instanceOf(Error) @@ -68,16 +75,16 @@ describe('Decision helper', () => { }) it('respects configured confidence', async () => { - decision = createDecision({ apiKey: 'secret', confidence: 0.95 }) + decision = createDecision({ confidence: 0.95 }) decision._actingHelper = () => browser - decision.decisionAI.fetchImpl = fakeFetch(noul(0.9), calls) + fetchImpl = fakeFetch(noul(0.9), calls) const err = await decision.decide('cart is empty').catch(e => e) expect(err.message).to.include('95%') }) it('checks all statements in a single request', async () => { - decision.decisionAI.fetchImpl = fakeFetch(noul(0.9, 0.99, 0.8), calls) + fetchImpl = fakeFetch(noul(0.9, 0.99, 0.8), calls) const probabilities = await decision.decide(['form has fields', 'submit enabled', 'cancel present']) expect(probabilities).to.eql([0.9, 0.99, 0.8]) @@ -87,7 +94,7 @@ describe('Decision helper', () => { }) it('lists only failed statements', async () => { - decision.decisionAI.fetchImpl = fakeFetch(noul(0.9, 0.1, 0.2), calls) + fetchImpl = fakeFetch(noul(0.9, 0.1, 0.2), calls) const err = await decision.decide(['form has fields', 'submit enabled', 'cancel present']).catch(e => e) expect(err.message).to.include('"submit enabled" (10%)') @@ -98,7 +105,7 @@ describe('Decision helper', () => { it('sends screenshot to visual model', async () => { const outputDir = store.outputDir store.outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'decision-')) - decision.decisionAI.fetchImpl = fakeFetch(noul(0.8), calls) + fetchImpl = fakeFetch(noul(0.8), calls) try { await decision.decideVisually('sidebar is shown') expect(fs.readdirSync(store.outputDir)).to.be.empty @@ -117,45 +124,29 @@ describe('Decision helper', () => { it('sends html when aria snapshot is not supported', async () => { decision._actingHelper = () => ({ ...browser, grabAriaSnapshot: undefined, grabSource: async () => '

Checkout

' }) - decision.decisionAI.fetchImpl = fakeFetch(noul(0.9), calls) + fetchImpl = fakeFetch(noul(0.9), calls) await decision.decide('heading is shown') expect(calls[0].body.state.aria).to.be.undefined expect(calls[0].body.state.html).to.include('

Checkout

') }) - it('uses AI SDK decision model from config', async () => { - const key = process.env.OPENROUTER_API_KEY - delete process.env.OPENROUTER_API_KEY - try { - const openrouter = createOpenRouter({ apiKey: 'config-key', fetch: fakeFetch(noul(0.9), calls) }) - decision = createDecision({ model: openrouter.evaluationModel('typesafe/jev-latest') }) - decision._actingHelper = () => browser - - expect(await decision.decide('page is loaded')).to.equal(0.9) - expect(calls[0].body.model).to.equal('typesafe/jev-latest') - expect(calls[0].headers.get('authorization')).to.equal('Bearer config-key') - } finally { - if (key) process.env.OPENROUTER_API_KEY = key - } - }) - it('reports http errors', async () => { - decision.decisionAI.fetchImpl = fakeFetch({}, calls, 402) + fetchImpl = fakeFetch({}, calls, 402) const err = await decision.decide('page is loaded').catch(e => e) expect(err.message).to.include('402') }) it('reports missing answers', async () => { - decision.decisionAI.fetchImpl = fakeFetch({}, calls) + fetchImpl = fakeFetch({}, calls) const err = await decision.decide('page is loaded').catch(e => e) expect(err.message).to.include('Decision must return exactly one answer for every question') }) it('times out hanging requests', async () => { - decision = createDecision({ apiKey: 'secret', timeout: 50 }) + decision = createDecision({ timeout: 50 }) decision._actingHelper = () => browser - decision.decisionAI.fetchImpl = (url, { signal }) => new Promise((_, reject) => signal.addEventListener('abort', () => reject(new Error('aborted')))) + fetchImpl = (url, { signal }) => new Promise((_, reject) => signal.addEventListener('abort', () => reject(new Error('aborted')))) const err = await decision.decide('page is loaded').catch(e => e) expect(err.message).to.include('did not respond in 50ms') @@ -163,9 +154,9 @@ describe('Decision helper', () => { }) it('times out stalled response body', async () => { - decision = createDecision({ apiKey: 'secret', timeout: 50 }) + decision = createDecision({ timeout: 50 }) decision._actingHelper = () => browser - decision.decisionAI.fetchImpl = async (url, { signal }) => { + fetchImpl = async (url, { signal }) => { const body = new ReadableStream({ start: controller => signal.addEventListener('abort', () => controller.error(new Error('aborted'))) }) return new Response(body, { headers: { 'content-type': 'application/json' } }) } @@ -176,13 +167,13 @@ describe('Decision helper', () => { }) it('marks failed decisions as not retryable', async () => { - decision.decisionAI.fetchImpl = fakeFetch(noul(0.1), calls) + fetchImpl = fakeFetch(noul(0.1), calls) const err = await decision.decide('cart is empty').catch(e => e) expect(err.isTerminal).to.equal(true) }) it('marks http errors as not retryable', async () => { - decision.decisionAI.fetchImpl = fakeFetch({}, calls, 500) + fetchImpl = fakeFetch({}, calls, 500) const err = await decision.decide('page is loaded').catch(e => e) expect(err.isTerminal).to.equal(true) }) @@ -195,39 +186,46 @@ describe('Decision helper', () => { }) it('keeps connection errors retryable', async () => { - decision.decisionAI.fetchImpl = async () => { - throw new TypeError('fetch failed') + fetchImpl = async () => { + throw new TypeError('fetch failed', { cause: new Error('connect ECONNREFUSED') }) } const err = await decision.decide('page is loaded').catch(e => e) - expect(err.message).to.include('request failed: fetch failed') + expect(err.message).to.include('request failed: Cannot connect to API: connect ECONNREFUSED') expect(err.isTerminal).to.be.undefined }) it('reads config from ai.decisionModel', () => { - decision = createDecision({ apiKey: 'secret', model: 'jev-latest', confidence: 0.9 }) - expect(decision.options.model).to.equal('jev-latest') + decision = createDecision({ confidence: 0.9 }) + expect(decision.options.model.modelId).to.equal('typesafe/jev-1.13') + expect(decision.options.visualModel.modelId).to.equal('cloudflare/clef') expect(decision.options.confidence).to.equal(0.9) - expect(decision.options.visualModel).to.equal('cloudflare/clef') }) it('validates config', () => { - expect(() => createDecision({ apiKey: 'secret', confidence: 1.5 })).to.throw('between 0 and 1') + expect(() => createDecision({ confidence: 1.5 })).to.throw('between 0 and 1') }) - it('requires api key only when deciding', async () => { - const key = process.env.OPENROUTER_API_KEY - delete process.env.OPENROUTER_API_KEY - try { - decision = createDecision({}) - decision._actingHelper = () => browser - decision.decisionAI.fetchImpl = fakeFetch(noul(0.9), calls) + it('requires decision model only when deciding', async () => { + decision = createDecision({ model: undefined }) + decision._actingHelper = () => browser + fetchImpl = fakeFetch(noul(0.9), calls) - const err = await decision.decide('page is loaded').catch(e => e) - expect(err.message).to.include('OPENROUTER_API_KEY') - expect(calls).to.be.empty - } finally { - if (key) process.env.OPENROUTER_API_KEY = key - } + const err = await decision.decide('page is loaded').catch(e => e) + expect(err.message).to.include('No decision model is set in ai.decisionModel.model') + expect(err.message).to.include("openrouter.evaluationModel('typesafe/jev-1.13')") + expect(calls).to.be.empty + }) + + it('requires visual model for visual decisions', async () => { + decision = createDecision({ visualModel: undefined }) + decision._actingHelper = () => browser + const outputDir = store.outputDir + store.outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'decision-')) + + const err = await decision.decideVisually('sidebar is shown').catch(e => e) + fs.rmSync(store.outputDir, { recursive: true, force: true }) + store.outputDir = outputDir + expect(err.message).to.include('No decision model is set in ai.decisionModel.visualModel') }) describe('mode', () => { @@ -237,7 +235,7 @@ describe('Decision helper', () => { it('skips decisions without requests', async () => { decision = createDecision({}, { mode: 'skip' }) - decision.decisionAI.fetchImpl = fakeFetch(noul(0.1), calls) + fetchImpl = fakeFetch(noul(0.1), calls) store.currentStep = { comment: '' } expect(await decision.decide('cart is empty')).to.be.undefined @@ -247,9 +245,9 @@ describe('Decision helper', () => { }) it('reports results in step comment without failing', async () => { - decision = createDecision({ apiKey: 'secret' }, { mode: 'report' }) + decision = createDecision({}, { mode: 'report' }) decision._actingHelper = () => browser - decision.decisionAI.fetchImpl = fakeFetch(noul(0.9, 0.1), calls) + fetchImpl = fakeFetch(noul(0.9, 0.1), calls) store.currentStep = { comment: '' } const probabilities = await decision.decide(['form has fields', 'submit enabled']) @@ -260,9 +258,9 @@ describe('Decision helper', () => { }) it('reports api errors in step comment without failing', async () => { - decision = createDecision({ apiKey: 'secret' }, { mode: 'report' }) + decision = createDecision({}, { mode: 'report' }) decision._actingHelper = () => browser - decision.decisionAI.fetchImpl = fakeFetch({}, calls, 500) + fetchImpl = fakeFetch({}, calls, 500) store.currentStep = { comment: '' } expect(await decision.decide('page is loaded')).to.be.undefined From 7d2dd423f1c58e05f21973f6a82a448db290cf07 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Fri, 9 Oct 2026 03:51:12 +0300 Subject: [PATCH 5/5] fix(deps): pin ai to 7.0.132 ai 7.0.133 wraps decision state into SDK parts that @openrouter/ai-sdk-provider 3.1.0 forwards untranslated, so OpenRouter receives wrapped state and screenshots lose the image_url format. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5fb795b94..7d88c05d8 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,7 @@ "@modelcontextprotocol/sdk": "^1.26.0", "@xmldom/xmldom": "0.9.10", "acorn": "8.15.0", - "ai": "^7.0.131", + "ai": "7.0.132", "arrify": "3.0.0", "axios": "1.16.1", "chalk": "4.1.2",