Skip to content

Commit 827a459

Browse files
DavertMikclaude
andcommitted
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 <noreply@anthropic.com>
1 parent e614e5d commit 827a459

4 files changed

Lines changed: 50 additions & 51 deletions

File tree

‎lib/ai.js‎

Lines changed: 37 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ const debug = debugModule('codeceptjs:ai')
33
import output from './output.js'
44
import event from './event.js'
55
import { removeNonInteractiveElements, minifyHtml, splitByChunks } from './html.js'
6-
import { generateText } from 'ai'
6+
import { generateText, experimental_decide as decide, APICallError } from 'ai'
7+
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
8+
import { createTypeSafeAi } from '@ai-sdk/typesafe-ai'
79
import { fileURLToPath } from 'url'
810
import path from 'path'
911
import { fileExists, resolveImportModulePath } from './utils.js'
@@ -271,9 +273,9 @@ function parseCodeBlocks(response) {
271273
return modifiedSnippets.filter(snippet => !!snippet)
272274
}
273275

274-
const DECISION_ENDPOINTS = {
275-
openrouter: { url: 'https://openrouter.ai/api/alpha/decisions', keyName: 'OPENROUTER_API_KEY' },
276-
typesafe: { url: 'https://api.typesafe.ai/v1/systemone', keyName: 'TYPESAFE_API_KEY' },
276+
const DECISION_PROVIDERS = {
277+
openrouter: { keyName: 'OPENROUTER_API_KEY' },
278+
typesafe: { keyName: 'TYPESAFE_API_KEY' },
277279
}
278280

279281
const defaultDecisionConfig = {
@@ -292,20 +294,20 @@ class DecisionAI {
292294
this.config = { ...defaultDecisionConfig, ...config }
293295

294296
const { provider, confidence } = this.config
295-
this.endpoint = DECISION_ENDPOINTS[provider]
296-
if (!this.endpoint) throw new Error(`Unknown decision provider "${provider}" in ai.decisionModel, use one of: ${Object.keys(DECISION_ENDPOINTS).join(', ')}`)
297+
this.provider = DECISION_PROVIDERS[provider]
298+
if (!this.provider) throw new Error(`Unknown decision provider "${provider}" in ai.decisionModel, use one of: ${Object.keys(DECISION_PROVIDERS).join(', ')}`)
297299
if (!(confidence > 0 && confidence < 1)) throw new Error(`ai.decisionModel.confidence must be between 0 and 1, got ${confidence}`)
298300

299301
this.fetchImpl = fetch
300302
}
301303

302304
checkModel() {
303-
if (this.config.apiKey || process.env[this.endpoint.keyName]) return
305+
if (this.config.apiKey || process.env[this.provider.keyName]) return
304306

305307
const noKeyErrorMessage = `
306308
No API key is set for decision model.
307309
308-
[!] Set ${this.endpoint.keyName} environment variable or apiKey in ai.decisionModel config.
310+
[!] Set ${this.provider.keyName} environment variable or apiKey in ai.decisionModel config.
309311
310312
Example (connect to OpenRouter, default):
311313
@@ -340,48 +342,48 @@ class DecisionAI {
340342
async decide(model, state, statements) {
341343
this.checkModel()
342344

343-
const apiKey = this.config.apiKey || process.env[this.endpoint.keyName]
344-
const questions = Object.fromEntries(statements.map((statement, i) => [`q${i}`, { type: 'noul', instructions: statement }]))
345+
const questions = Object.fromEntries(statements.map((statement, i) => [`q${i}`, { type: 'boolean', instructions: statement }]))
345346
debug('Decision request', model, statements)
346347

347348
const controller = new AbortController()
348349
const timer = setTimeout(() => controller.abort(), this.config.timeout)
349350

350351
let result
351352
try {
352-
let response
353-
try {
354-
response = await this.fetchImpl(this.endpoint.url, {
355-
method: 'POST',
356-
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
357-
body: JSON.stringify({ model, state, questions }),
358-
signal: controller.signal,
359-
})
360-
} catch (err) {
361-
throw new DecisionConnectionError(`Decision model ${model} request failed: ${err.message}`)
362-
}
363-
364-
if (!response.ok) {
365-
const body = await response.text().catch(() => '')
366-
throw new Error(`Decision model ${model} responded with ${response.status}: ${body}`)
367-
}
368-
369-
result = await response.json()
353+
result = await decide({
354+
model: this.decisionModel(model),
355+
state,
356+
questions,
357+
maxRetries: 0,
358+
abortSignal: controller.signal,
359+
})
370360
} catch (err) {
371361
if (controller.signal.aborted) throw new DecisionConnectionError(`Decision model ${model} did not respond in ${this.config.timeout}ms`)
362+
if (APICallError.isInstance(err)) throw new Error(`Decision model ${model} responded with ${err.statusCode}: ${err.responseBody || err.message}`)
372363
throw err
373364
} finally {
374365
clearTimeout(timer)
375366
}
376367

377-
debug('Decision response', result?.answers, result?.usage)
368+
debug('Decision response', result.answers, result.usage)
378369

379-
const answers = result?.answers || {}
380-
return statements.map((statement, i) => {
381-
const probability = answers[`q${i}`]?.noul
382-
if (typeof probability !== 'number') throw new Error(`Decision model ${model} returned no answer for "${statement}"`)
383-
return probability
384-
})
370+
return Object.keys(questions).map(id => result.answers[id].probability)
371+
}
372+
373+
decisionModel(model) {
374+
const settings = {
375+
apiKey: this.config.apiKey || process.env[this.provider.keyName],
376+
fetch: async (url, options) => {
377+
try {
378+
return await this.fetchImpl(url, options)
379+
} catch (err) {
380+
if (options.signal?.aborted) throw err
381+
throw new DecisionConnectionError(`Decision model ${model} request failed: ${err.message}`)
382+
}
383+
},
384+
}
385+
if (this.config.provider === 'typesafe') return createTypeSafeAi(settings).decisionModel(model)
386+
return createOpenRouter(settings).evaluationModel(model)
385387
}
386388
}
387389

‎package.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,15 +93,17 @@
9393
"publish-beta": "./runok.cjs publish:next-beta-version"
9494
},
9595
"dependencies": {
96+
"@ai-sdk/typesafe-ai": "^3.0.15",
9697
"@codeceptjs/configure": "^4.0.0-beta.4",
9798
"@codeceptjs/helper": "2.0.4",
9899
"@cucumber/cucumber-expressions": "18",
99100
"@cucumber/gherkin": "38.0.0",
100101
"@cucumber/messages": "32.3.1",
101102
"@modelcontextprotocol/sdk": "^1.26.0",
103+
"@openrouter/ai-sdk-provider": "^3.1.0",
102104
"@xmldom/xmldom": "0.9.10",
103105
"acorn": "8.15.0",
104-
"ai": "^6.0.43",
106+
"ai": "^7.0.131",
105107
"arrify": "3.0.0",
106108
"axios": "1.16.1",
107109
"chalk": "4.1.2",

‎test/unit/ai_test.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,7 @@ describe('DecisionAI', () => {
325325
const decisionAI = new DecisionAI({ apiKey: 'secret' })
326326
decisionAI.fetchImpl = async (url, options) => {
327327
calls.push({ url, body: JSON.parse(options.body) })
328-
return { ok: true, json: async () => ({ answers: { q0: { type: 'noul', noul: 0.9 }, q1: { type: 'noul', noul: 0.2 } } }) }
328+
return Response.json({ answers: { q0: { type: 'noul', noul: 0.9 }, q1: { type: 'noul', noul: 0.2 } } })
329329
}
330330

331331
const probabilities = await decisionAI.decide('typesafe/jev-1.13', { url: 'http://localhost' }, ['form is shown', 'cart is empty'])

‎test/unit/helper/Decision_test.js‎

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,9 @@ function createDecision(decisionModel, config = {}) {
1313

1414
function fakeFetch(answers, calls, status = 200) {
1515
return async (url, options) => {
16-
calls.push({ url, body: JSON.parse(options.body), headers: options.headers })
17-
return {
18-
ok: status === 200,
19-
status,
20-
text: async () => 'boom',
21-
json: async () => ({ answers }),
22-
}
16+
calls.push({ url, body: JSON.parse(options.body), headers: new Headers(options.headers) })
17+
if (status !== 200) return new Response('boom', { status })
18+
return Response.json({ answers })
2319
}
2420
}
2521

@@ -53,7 +49,7 @@ describe('Decision helper', () => {
5349
expect(probability).to.equal(0.9)
5450
expect(calls).to.have.length(1)
5551
expect(calls[0].url).to.equal('https://openrouter.ai/api/alpha/decisions')
56-
expect(calls[0].headers.Authorization).to.equal('Bearer secret')
52+
expect(calls[0].headers.get('authorization')).to.equal('Bearer secret')
5753
expect(calls[0].body.model).to.equal('typesafe/jev-1.13')
5854
expect(calls[0].body.questions).to.eql({ q0: { type: 'noul', instructions: 'submit button is present' } })
5955
expect(calls[0].body.state.url).to.equal('http://localhost/checkout')
@@ -146,7 +142,7 @@ describe('Decision helper', () => {
146142
it('reports missing answers', async () => {
147143
decision.decisionAI.fetchImpl = fakeFetch({}, calls)
148144
const err = await decision.decide('page is loaded').catch(e => e)
149-
expect(err.message).to.include('returned no answer for "page is loaded"')
145+
expect(err.message).to.include('Decision must return exactly one answer for every question')
150146
})
151147

152148
it('times out hanging requests', async () => {
@@ -162,11 +158,10 @@ describe('Decision helper', () => {
162158
it('times out stalled response body', async () => {
163159
decision = createDecision({ apiKey: 'secret', timeout: 50 })
164160
decision._actingHelper = () => browser
165-
decision.decisionAI.fetchImpl = async (url, { signal }) => ({
166-
ok: true,
167-
status: 200,
168-
json: () => new Promise((_, reject) => signal.addEventListener('abort', () => reject(new Error('aborted')))),
169-
})
161+
decision.decisionAI.fetchImpl = async (url, { signal }) => {
162+
const body = new ReadableStream({ start: controller => signal.addEventListener('abort', () => controller.error(new Error('aborted'))) })
163+
return new Response(body, { headers: { 'content-type': 'application/json' } })
164+
}
170165

171166
const err = await decision.decide('page is loaded').catch(e => e)
172167
expect(err.message).to.include('did not respond in 50ms')

0 commit comments

Comments
 (0)