@owlmeans/llm 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +193 -0
- package/agent-meta/instructions/llm.instructions.md +66 -0
- package/agent-meta/manifest.json +23 -0
- package/agent-meta/skills/llm/SKILL.md +121 -0
- package/build/consts.d.ts +67 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +76 -0
- package/build/consts.js.map +1 -0
- package/build/errors.d.ts +33 -0
- package/build/errors.d.ts.map +1 -0
- package/build/errors.js +52 -0
- package/build/errors.js.map +1 -0
- package/build/execution/index.d.ts +4 -0
- package/build/execution/index.d.ts.map +1 -0
- package/build/execution/index.js +3 -0
- package/build/execution/index.js.map +1 -0
- package/build/execution/service.d.ts +21 -0
- package/build/execution/service.d.ts.map +1 -0
- package/build/execution/service.js +129 -0
- package/build/execution/service.js.map +1 -0
- package/build/execution/types.d.ts +119 -0
- package/build/execution/types.d.ts.map +1 -0
- package/build/execution/types.js +2 -0
- package/build/execution/types.js.map +1 -0
- package/build/execution/utils.d.ts +28 -0
- package/build/execution/utils.d.ts.map +1 -0
- package/build/execution/utils.js +60 -0
- package/build/execution/utils.js.map +1 -0
- package/build/helpers/index.d.ts +5 -0
- package/build/helpers/index.d.ts.map +1 -0
- package/build/helpers/index.js +5 -0
- package/build/helpers/index.js.map +1 -0
- package/build/helpers/json.d.ts +24 -0
- package/build/helpers/json.d.ts.map +1 -0
- package/build/helpers/json.js +119 -0
- package/build/helpers/json.js.map +1 -0
- package/build/helpers/messages.d.ts +10 -0
- package/build/helpers/messages.d.ts.map +1 -0
- package/build/helpers/messages.js +9 -0
- package/build/helpers/messages.js.map +1 -0
- package/build/helpers/retry.d.ts +18 -0
- package/build/helpers/retry.d.ts.map +1 -0
- package/build/helpers/retry.js +57 -0
- package/build/helpers/retry.js.map +1 -0
- package/build/helpers/spectate.d.ts +8 -0
- package/build/helpers/spectate.d.ts.map +1 -0
- package/build/helpers/spectate.js +54 -0
- package/build/helpers/spectate.js.map +1 -0
- package/build/index.d.ts +13 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +11 -0
- package/build/index.js.map +1 -0
- package/build/model.d.ts +12 -0
- package/build/model.d.ts.map +1 -0
- package/build/model.js +297 -0
- package/build/model.js.map +1 -0
- package/build/plugins/anthropic.d.ts +4 -0
- package/build/plugins/anthropic.d.ts.map +1 -0
- package/build/plugins/anthropic.js +86 -0
- package/build/plugins/anthropic.js.map +1 -0
- package/build/plugins/compatible.d.ts +18 -0
- package/build/plugins/compatible.d.ts.map +1 -0
- package/build/plugins/compatible.js +54 -0
- package/build/plugins/compatible.js.map +1 -0
- package/build/plugins/export.d.ts +6 -0
- package/build/plugins/export.d.ts.map +1 -0
- package/build/plugins/export.js +5 -0
- package/build/plugins/export.js.map +1 -0
- package/build/plugins/index.d.ts +18 -0
- package/build/plugins/index.d.ts.map +1 -0
- package/build/plugins/index.js +42 -0
- package/build/plugins/index.js.map +1 -0
- package/build/plugins/openai.d.ts +28 -0
- package/build/plugins/openai.d.ts.map +1 -0
- package/build/plugins/openai.js +89 -0
- package/build/plugins/openai.js.map +1 -0
- package/build/plugins/types.d.ts +80 -0
- package/build/plugins/types.d.ts.map +1 -0
- package/build/plugins/types.js +2 -0
- package/build/plugins/types.js.map +1 -0
- package/build/plugins/utils.d.ts +27 -0
- package/build/plugins/utils.d.ts.map +1 -0
- package/build/plugins/utils.js +33 -0
- package/build/plugins/utils.js.map +1 -0
- package/build/service.d.ts +24 -0
- package/build/service.d.ts.map +1 -0
- package/build/service.js +95 -0
- package/build/service.js.map +1 -0
- package/build/types.d.ts +190 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/build/utils/config.d.ts +13 -0
- package/build/utils/config.d.ts.map +1 -0
- package/build/utils/config.js +15 -0
- package/build/utils/config.js.map +1 -0
- package/build/utils/null-report.d.ts +36 -0
- package/build/utils/null-report.d.ts.map +1 -0
- package/build/utils/null-report.js +84 -0
- package/build/utils/null-report.js.map +1 -0
- package/build/utils/prompt.d.ts +15 -0
- package/build/utils/prompt.d.ts.map +1 -0
- package/build/utils/prompt.js +45 -0
- package/build/utils/prompt.js.map +1 -0
- package/build/utils/schema.d.ts +20 -0
- package/build/utils/schema.d.ts.map +1 -0
- package/build/utils/schema.js +28 -0
- package/build/utils/schema.js.map +1 -0
- package/build/utils/stream.d.ts +22 -0
- package/build/utils/stream.d.ts.map +1 -0
- package/build/utils/stream.js +54 -0
- package/build/utils/stream.js.map +1 -0
- package/package.json +65 -0
- package/src/consts.ts +89 -0
- package/src/errors.ts +65 -0
- package/src/execution/index.ts +4 -0
- package/src/execution/service.ts +185 -0
- package/src/execution/types.ts +139 -0
- package/src/execution/utils.ts +79 -0
- package/src/helpers/index.ts +5 -0
- package/src/helpers/json.ts +117 -0
- package/src/helpers/messages.ts +12 -0
- package/src/helpers/retry.ts +59 -0
- package/src/helpers/spectate.ts +67 -0
- package/src/index.ts +13 -0
- package/src/model.ts +379 -0
- package/src/plugins/anthropic.ts +97 -0
- package/src/plugins/compatible.ts +62 -0
- package/src/plugins/export.ts +6 -0
- package/src/plugins/index.ts +53 -0
- package/src/plugins/openai.ts +108 -0
- package/src/plugins/types.ts +92 -0
- package/src/plugins/utils.ts +38 -0
- package/src/service.ts +125 -0
- package/src/types.ts +214 -0
- package/src/utils/config.ts +19 -0
- package/src/utils/null-report.ts +126 -0
- package/src/utils/prompt.ts +46 -0
- package/src/utils/schema.ts +35 -0
- package/src/utils/stream.ts +58 -0
- package/tests/context.ts +110 -0
- package/tests/execution.spec.ts +200 -0
- package/tests/helpers.spec.ts +192 -0
- package/tests/internals.spec.ts +141 -0
- package/tests/model.spec.ts +116 -0
- package/tests/plugins.spec.ts +227 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { ExecutionEffort, ExecutionLevel } from '@owlmeans/llm-common'
|
|
3
|
+
import type { ExecutionState, ModelConfigPatch, TaskExecutionState } from '@owlmeans/llm-common'
|
|
4
|
+
import { DEFAULT_EFFORT, EFFORT_TABLE, makeExecutionService, makeLlmService } from '@owlmeans/llm'
|
|
5
|
+
import type { ExecutionService, ProjectExecution, TaskExecution } from '@owlmeans/llm'
|
|
6
|
+
import { offlineConfigs, Role } from './context.js'
|
|
7
|
+
|
|
8
|
+
let service: ExecutionService
|
|
9
|
+
let root: ProjectExecution
|
|
10
|
+
/** Every `getModel` call the executions made, in order — asserts policy resolution. */
|
|
11
|
+
let resolved: Array<{ alias: string, override?: Partial<ModelConfigPatch> }>
|
|
12
|
+
|
|
13
|
+
const makeService = () => {
|
|
14
|
+
const llm = makeLlmService({ models: offlineConfigs }, `spec-exec-llm-${resolved.length}`)
|
|
15
|
+
const recording = {
|
|
16
|
+
...llm,
|
|
17
|
+
getModel: (alias: string, override?: Partial<ModelConfigPatch>) => {
|
|
18
|
+
resolved.push({ alias, override })
|
|
19
|
+
return llm.getModel(alias, override)
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
return () => recording as unknown as ReturnType<typeof makeLlmService>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
resolved = []
|
|
27
|
+
service = makeExecutionService(`spec-exec-${Math.trunc(performance.now() * 1000)}`)
|
|
28
|
+
root = service.root({
|
|
29
|
+
models: makeService(),
|
|
30
|
+
policy: { effort: DEFAULT_EFFORT },
|
|
31
|
+
purpose: { type: 'spec' },
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('@owlmeans/llm — execution immutability', () => {
|
|
36
|
+
test('every construction and refinement returns a frozen object', () => {
|
|
37
|
+
expect(Object.isFrozen(root)).toBe(true)
|
|
38
|
+
expect(Object.isFrozen(service.forTask(root, {}))).toBe(true)
|
|
39
|
+
expect(Object.isFrozen(service.forHelper(root, { role: Role.Analyst }))).toBe(true)
|
|
40
|
+
expect(Object.isFrozen(service.derive(root, {}))).toBe(true)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('refining never mutates the parent', () => {
|
|
44
|
+
const escalated = service.escalate(root, { effort: ExecutionEffort.Max })
|
|
45
|
+
expect(escalated).not.toBe(root)
|
|
46
|
+
expect(root.policy.effort).toBe(DEFAULT_EFFORT)
|
|
47
|
+
expect(escalated.policy.effort).toBe(ExecutionEffort.Max)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('purpose refinement is additive and local', () => {
|
|
51
|
+
const dedicated = service.withPurpose(root, { dedication: 'inner' })
|
|
52
|
+
expect(dedicated.purpose).toEqual({ type: 'spec', dedication: 'inner' })
|
|
53
|
+
expect(root.purpose.dedication).toBeUndefined()
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('@owlmeans/llm — execution levels', () => {
|
|
58
|
+
test('a task inherits the parent context and composes its own state', () => {
|
|
59
|
+
const task = service.forTask(root, { phase: 'draft', data: { step: 1 } })
|
|
60
|
+
expect(task.level).toBe(ExecutionLevel.Task)
|
|
61
|
+
expect(task.state.phase).toBe('draft')
|
|
62
|
+
expect(task.state.data).toEqual({ step: 1 })
|
|
63
|
+
expect(task.models).toBe(root.models)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('a task effort applies to the task and everything derived from it', () => {
|
|
67
|
+
const task = service.forTask(root, { effort: ExecutionEffort.High })
|
|
68
|
+
expect(task.policy.effort).toBe(ExecutionEffort.High)
|
|
69
|
+
expect(service.forHelper(task, { role: Role.Analyst }).policy.effort).toBe(ExecutionEffort.High)
|
|
70
|
+
expect(root.policy.effort).toBe(DEFAULT_EFFORT)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('a helper resolves a model and a temperature factory bound to its role', () => {
|
|
74
|
+
const helper = service.forHelper(root, { role: Role.Analyst, dedication: 'summarise' })
|
|
75
|
+
expect(helper.level).toBe(ExecutionLevel.Helper)
|
|
76
|
+
expect(helper.role).toBe(Role.Analyst)
|
|
77
|
+
expect(helper.model).toBeDefined()
|
|
78
|
+
expect(helper.purpose.dedication).toBe('summarise')
|
|
79
|
+
expect(resolved[0]?.alias).toBe(Role.Analyst)
|
|
80
|
+
|
|
81
|
+
helper.temperatureFactory(0.9)
|
|
82
|
+
expect(resolved.at(-1)).toEqual({ alias: Role.Analyst, override: { temperature: 0.9, topP: 0.8 } })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('a helper effort bump does not escalate the branch it came from', () => {
|
|
86
|
+
const helper = service.forHelper(root, { role: Role.Analyst, effort: ExecutionEffort.Max })
|
|
87
|
+
expect(helper.policy.effort).toBe(ExecutionEffort.Max)
|
|
88
|
+
expect(root.policy.effort).toBe(DEFAULT_EFFORT)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('a helper derived from a task carries no resumable state of its own', () => {
|
|
92
|
+
const task = service.forTask(root, { phase: 'draft' })
|
|
93
|
+
const helper = service.forHelper(task, { role: Role.Analyst })
|
|
94
|
+
expect((helper as unknown as { state?: unknown }).state).toBeUndefined()
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('@owlmeans/llm — model policy resolution', () => {
|
|
99
|
+
test('the effort tier is merged into the resolution override', () => {
|
|
100
|
+
const high = service.escalate(root, { effort: ExecutionEffort.High })
|
|
101
|
+
service.model(high, Role.Analyst)
|
|
102
|
+
expect(resolved.at(-1)?.override).toEqual(EFFORT_TABLE[ExecutionEffort.High])
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('a role override remaps which role is actually resolved', () => {
|
|
106
|
+
const remapped = service.escalate(root, { roleOverrides: { [Role.Analyst]: Role.Picker } })
|
|
107
|
+
service.model(remapped, Role.Analyst)
|
|
108
|
+
expect(resolved.at(-1)?.alias).toBe(Role.Picker)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('an explicit model override wins over the effort tier', () => {
|
|
112
|
+
const pinned = service.escalate(root, {
|
|
113
|
+
effort: ExecutionEffort.High,
|
|
114
|
+
modelOverrides: { [Role.Analyst]: { maxTokens: 999 } },
|
|
115
|
+
})
|
|
116
|
+
service.model(pinned, Role.Analyst)
|
|
117
|
+
expect(resolved.at(-1)?.override).toEqual({ ...EFFORT_TABLE[ExecutionEffort.High], maxTokens: 999 })
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('a call-site override wins over the policy', () => {
|
|
121
|
+
const pinned = service.escalate(root, { modelOverrides: { [Role.Analyst]: { maxTokens: 999 } } })
|
|
122
|
+
service.model(pinned, Role.Analyst, { maxTokens: 111 })
|
|
123
|
+
expect(resolved.at(-1)?.override).toEqual({ maxTokens: 111 })
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('a string override is read as a preset alias', () => {
|
|
127
|
+
const pinned = service.escalate(root, { modelOverrides: { [Role.Analyst]: Role.Picker } })
|
|
128
|
+
service.model(pinned, Role.Analyst)
|
|
129
|
+
expect(resolved.at(-1)?.override).toEqual({ preset: Role.Picker })
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('undefined keys never reach the factory', () => {
|
|
133
|
+
service.model(root, Role.Analyst, { maxTokens: undefined })
|
|
134
|
+
expect(resolved.at(-1)?.override).toEqual({})
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('@owlmeans/llm — snapshot and restore', () => {
|
|
139
|
+
test('a snapshot carries the state and none of the collaborators', () => {
|
|
140
|
+
const state = service.snapshot(root) as ExecutionState & Record<string, unknown>
|
|
141
|
+
expect(state.level).toBe(ExecutionLevel.Project)
|
|
142
|
+
expect(state.purpose).toEqual({ type: 'spec' })
|
|
143
|
+
expect(state.models).toBeUndefined()
|
|
144
|
+
expect(JSON.stringify(state)).toBeString()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// Regression: `composeExecState` used to copy `state` itself, so every refinement of a
|
|
148
|
+
// task nested another copy of the previous state (state.state.state…), growing the
|
|
149
|
+
// snapshot without bound.
|
|
150
|
+
test('repeatedly refining a task never nests its state', () => {
|
|
151
|
+
let task: TaskExecution = service.forTask(root, { phase: 'draft' })
|
|
152
|
+
for (let i = 0; i < 5; i++) {
|
|
153
|
+
task = service.escalate(task, { effort: ExecutionEffort.High })
|
|
154
|
+
task = service.withPurpose(task, { dedication: `round-${i}` })
|
|
155
|
+
task = service.derive(task, {})
|
|
156
|
+
}
|
|
157
|
+
expect((task.state as unknown as { state?: unknown }).state).toBeUndefined()
|
|
158
|
+
expect((service.snapshot(task) as unknown as { state?: unknown }).state).toBeUndefined()
|
|
159
|
+
expect(task.state.phase).toBe('draft')
|
|
160
|
+
expect(task.purpose.dedication).toBe('round-4')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('a task snapshot keeps the resumable fields across refinement', () => {
|
|
164
|
+
const task = service.forTask(root, { phase: 'draft', data: { step: 1 } })
|
|
165
|
+
const advanced = service.escalate(task, { effort: ExecutionEffort.Max })
|
|
166
|
+
const state = service.snapshot(advanced) as TaskExecutionState
|
|
167
|
+
expect(state.phase).toBe('draft')
|
|
168
|
+
expect(state.data).toEqual({ step: 1 })
|
|
169
|
+
expect(state.policy.effort).toBe(ExecutionEffort.Max)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test('restore re-attaches the collaborators to a persisted state', () => {
|
|
173
|
+
const task = service.forTask(root, { phase: 'draft' })
|
|
174
|
+
const state = JSON.parse(JSON.stringify(service.snapshot(task))) as ExecutionState
|
|
175
|
+
const restored = service.restore(state, { models: root.models })
|
|
176
|
+
expect(restored.level).toBe(ExecutionLevel.Task)
|
|
177
|
+
expect(restored.models).toBe(root.models)
|
|
178
|
+
expect((restored as TaskExecution).state.phase).toBe('draft')
|
|
179
|
+
expect(Object.isFrozen(restored)).toBe(true)
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
describe('@owlmeans/llm — resilience plugin seam', () => {
|
|
184
|
+
test('checkpoint is a no-op until a plugin is registered', async () => {
|
|
185
|
+
await expect(service.checkpoint(root, 'key')).resolves.toBeUndefined()
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('a registered plugin receives the JSON-safe state and the execution', async () => {
|
|
189
|
+
const seen: Array<{ state: ExecutionState, key?: string }> = []
|
|
190
|
+
service.use({ onCheckpoint: async (state, _exec, key) => { seen.push({ state, key }) } })
|
|
191
|
+
|
|
192
|
+
const task = service.forTask(root, { phase: 'draft' })
|
|
193
|
+
await service.checkpoint(task, 'project-1')
|
|
194
|
+
|
|
195
|
+
expect(seen).toHaveLength(1)
|
|
196
|
+
expect(seen[0]!.key).toBe('project-1')
|
|
197
|
+
expect((seen[0]!.state as TaskExecutionState).phase).toBe('draft')
|
|
198
|
+
expect((seen[0]!.state as unknown as { models?: unknown }).models).toBeUndefined()
|
|
199
|
+
})
|
|
200
|
+
})
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { AIMessage, HumanMessage } from '@langchain/core/messages'
|
|
3
|
+
import {
|
|
4
|
+
coerceToSchema, LlmRetryExceededError, normalizeInput, parseJsonContent, registerFatalError,
|
|
5
|
+
spectate, withRetry,
|
|
6
|
+
} from '@owlmeans/llm'
|
|
7
|
+
import { recordingSpectator } from './context.js'
|
|
8
|
+
|
|
9
|
+
describe('helpers/messages — input normalization', () => {
|
|
10
|
+
test('lifts a bare string into a user message', () => {
|
|
11
|
+
expect(normalizeInput('hello')).toEqual([{ role: 'user', content: 'hello' }])
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('wraps a single message and passes an array through', () => {
|
|
15
|
+
const msg = { role: 'system' as const, content: 'be terse' }
|
|
16
|
+
expect(normalizeInput(msg)).toEqual([msg])
|
|
17
|
+
expect(normalizeInput([msg, 'hi'])).toEqual([msg, { role: 'user', content: 'hi' }])
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('produces a fresh array the model may mutate without touching the caller', () => {
|
|
21
|
+
const input = [{ role: 'user' as const, content: 'hi' }]
|
|
22
|
+
const normalized = normalizeInput(input)
|
|
23
|
+
normalized.push({ role: 'user', content: 'extra' })
|
|
24
|
+
expect(input).toHaveLength(1)
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('helpers/json — recovering JSON a model emitted as content', () => {
|
|
29
|
+
test('parses plain JSON', () => {
|
|
30
|
+
expect(parseJsonContent('{"a":1}')).toEqual({ a: 1 })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('unwraps a markdown fence', () => {
|
|
34
|
+
expect(parseJsonContent('```json\n{"a":1}\n```')).toEqual({ a: 1 })
|
|
35
|
+
expect(parseJsonContent('```\n[1,2]\n```')).toEqual([1, 2])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('salvages an object from surrounding prose', () => {
|
|
39
|
+
expect(parseJsonContent('Sure! Here it is: {"a":1} — hope that helps')).toEqual({ a: 1 })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('salvages an array from surrounding prose', () => {
|
|
43
|
+
expect(parseJsonContent('Result: ["x","y"] done')).toEqual(['x', 'y'])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('reads structured content blocks', () => {
|
|
47
|
+
expect(parseJsonContent([{ type: 'text', text: '{"a":' }, { type: 'text', text: '1}' }])).toEqual({ a: 1 })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('returns null when there is nothing parseable', () => {
|
|
51
|
+
expect(parseJsonContent('no json here')).toBeNull()
|
|
52
|
+
expect(parseJsonContent('')).toBeNull()
|
|
53
|
+
expect(parseJsonContent(null)).toBeNull()
|
|
54
|
+
expect(parseJsonContent(42)).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('helpers/json — reconciling a model answer with its schema', () => {
|
|
59
|
+
// Observed in the wild: a model fills an `array` field with the STRING "[]".
|
|
60
|
+
test('parses stringified arrays and objects back', () => {
|
|
61
|
+
const schema = {
|
|
62
|
+
type: 'object',
|
|
63
|
+
properties: { files: { type: 'array', items: { type: 'string' } } },
|
|
64
|
+
}
|
|
65
|
+
expect(coerceToSchema({ files: '["a","b"]' }, schema)).toEqual({ files: ['a', 'b'] })
|
|
66
|
+
expect(coerceToSchema({ files: '[]' }, schema)).toEqual({ files: [] })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('coerces stringified scalars to their declared type', () => {
|
|
70
|
+
const schema = {
|
|
71
|
+
type: 'object',
|
|
72
|
+
properties: { count: { type: 'integer' }, ok: { type: 'boolean' }, ratio: { type: 'number' } },
|
|
73
|
+
}
|
|
74
|
+
expect(coerceToSchema({ count: '7', ok: 'true', ratio: '0.5' }, schema))
|
|
75
|
+
.toEqual({ count: 7, ok: true, ratio: 0.5 })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// A value that cannot be coerced is kept, so validation reports the real problem.
|
|
79
|
+
test('keeps an uncoercible value untouched', () => {
|
|
80
|
+
const schema = { type: 'object', properties: { count: { type: 'integer' } } }
|
|
81
|
+
expect(coerceToSchema({ count: 'seven' }, schema)).toEqual({ count: 'seven' })
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// Also observed: a `string[]` field filled with `[{ path: '…' }]`.
|
|
85
|
+
test('unwraps a scalar the model over-wrapped in an object', () => {
|
|
86
|
+
const schema = { type: 'array', items: { type: 'string' } }
|
|
87
|
+
expect(coerceToSchema([{ path: 'src/a.ts' }, { value: 'src/b.ts' }], schema))
|
|
88
|
+
.toEqual(['src/a.ts', 'src/b.ts'])
|
|
89
|
+
expect(coerceToSchema([{ onlyOne: 'src/c.ts' }], schema)).toEqual(['src/c.ts'])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('walks nested structures', () => {
|
|
93
|
+
const schema = {
|
|
94
|
+
type: 'object',
|
|
95
|
+
properties: {
|
|
96
|
+
items: {
|
|
97
|
+
type: 'array',
|
|
98
|
+
items: { type: 'object', properties: { n: { type: 'integer' } } },
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
expect(coerceToSchema({ items: [{ n: '1' }, { n: '2' }] }, schema))
|
|
103
|
+
.toEqual({ items: [{ n: 1 }, { n: 2 }] })
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('leaves conforming values and unknown schemas alone', () => {
|
|
107
|
+
expect(coerceToSchema({ a: 1 }, { type: 'object', properties: { a: { type: 'integer' } } })).toEqual({ a: 1 })
|
|
108
|
+
expect(coerceToSchema('x', undefined)).toBe('x')
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('helpers/retry', () => {
|
|
113
|
+
test('returns the first success and reports the attempt index', async () => {
|
|
114
|
+
const attempts: number[] = []
|
|
115
|
+
const result = await withRetry({ retries: 5 }, async i => {
|
|
116
|
+
attempts.push(i)
|
|
117
|
+
if (i < 2) throw new Error('transient')
|
|
118
|
+
return 'ok'
|
|
119
|
+
})
|
|
120
|
+
expect(result).toBe('ok')
|
|
121
|
+
expect(attempts).toEqual([0, 1, 2])
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('exhausting the budget throws, carrying the last error and attempt', async () => {
|
|
125
|
+
const failure = new Error('always')
|
|
126
|
+
let thrown: unknown
|
|
127
|
+
try {
|
|
128
|
+
await withRetry({ retries: 3 }, async () => { throw failure })
|
|
129
|
+
} catch (e) {
|
|
130
|
+
thrown = e
|
|
131
|
+
}
|
|
132
|
+
expect(thrown).toBeInstanceOf(LlmRetryExceededError)
|
|
133
|
+
expect((thrown as LlmRetryExceededError).cause).toBe(failure)
|
|
134
|
+
expect((thrown as LlmRetryExceededError).attempt).toBe(2)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('a per-call fatal predicate aborts immediately', async () => {
|
|
138
|
+
let calls = 0
|
|
139
|
+
const boom = new Error('unrecoverable')
|
|
140
|
+
await expect(withRetry(
|
|
141
|
+
{ retries: 5, fatal: e => e === boom ? boom : null },
|
|
142
|
+
async () => { calls += 1; throw boom }
|
|
143
|
+
)).rejects.toThrow('unrecoverable')
|
|
144
|
+
expect(calls).toBe(1)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// This is how a host registers "budget exhausted" so a model call stops retrying.
|
|
148
|
+
test('a globally registered resolver aborts every retry loop', async () => {
|
|
149
|
+
class SpecBudgetError extends Error { }
|
|
150
|
+
registerFatalError(e => e instanceof SpecBudgetError ? e : null)
|
|
151
|
+
|
|
152
|
+
let calls = 0
|
|
153
|
+
await expect(withRetry({ retries: 5 }, async () => {
|
|
154
|
+
calls += 1
|
|
155
|
+
throw new SpecBudgetError('out of budget')
|
|
156
|
+
})).rejects.toThrow('out of budget')
|
|
157
|
+
expect(calls).toBe(1)
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe('helpers/spectate', () => {
|
|
162
|
+
test('logs every prompt message plus the completion, in order', async () => {
|
|
163
|
+
const spectator = recordingSpectator()
|
|
164
|
+
await spectate(spectator, 'ask')(
|
|
165
|
+
[new HumanMessage('question'), { role: 'system', content: 'be terse' }, 'plain'],
|
|
166
|
+
new AIMessage('answer'), 'test-action', 2, 1000,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
expect(spectator.entries).toHaveLength(1)
|
|
170
|
+
const entry = spectator.entries[0]!
|
|
171
|
+
expect(entry.action).toBe('test-action')
|
|
172
|
+
expect(entry.retries).toBe(2)
|
|
173
|
+
expect(entry.startedAt).toBe(1000)
|
|
174
|
+
expect(entry.messages).toHaveLength(4)
|
|
175
|
+
expect(entry.messages.map(m => m.callType)).toEqual(['ask', 'ask', 'ask', 'ask'])
|
|
176
|
+
expect(entry.messages[1]!.type).toBe('system')
|
|
177
|
+
expect(entry.messages[3]!.content).toBe('answer')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('records tool-call arguments rather than the empty content of a tool completion', async () => {
|
|
181
|
+
const spectator = recordingSpectator()
|
|
182
|
+
const completion = new AIMessage({
|
|
183
|
+
content: '',
|
|
184
|
+
tool_calls: [{ id: '1', name: 'extract', args: { a: 1 } }],
|
|
185
|
+
})
|
|
186
|
+
await spectate(spectator, 'invoke')(['q'], completion, 'structured', 0)
|
|
187
|
+
|
|
188
|
+
const last = spectator.entries[0]!.messages.at(-1)!
|
|
189
|
+
expect(last.contentType).toBe('tool_call')
|
|
190
|
+
expect(last.content).toEqual([{ id: '1', name: 'extract', args: { a: 1 } }] as unknown as string)
|
|
191
|
+
})
|
|
192
|
+
})
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { MessageFieldWithRole } from '@langchain/core/messages'
|
|
3
|
+
import { JSON_INSTRUCTION, NO_THINK_DIRECTIVE } from '../src/consts.js'
|
|
4
|
+
import { applyNoThink, ensureJsonMention } from '../src/utils/prompt.js'
|
|
5
|
+
import { toToolName, unwrapNamed } from '../src/utils/schema.js'
|
|
6
|
+
import { getChunkFinishReason, streamWithDeadline } from '../src/utils/stream.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Internal utilities — deliberately not part of the package surface (`utils/` is
|
|
10
|
+
* library-private; `helpers/` is what a consumer may use alongside a model), so they are
|
|
11
|
+
* imported from source.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
describe('utils/prompt — JSON mention', () => {
|
|
15
|
+
test('appends the instruction when nothing mentions JSON', () => {
|
|
16
|
+
const msgs: MessageFieldWithRole[] = [{ role: 'user', content: 'Describe the project' }]
|
|
17
|
+
ensureJsonMention(msgs)
|
|
18
|
+
expect(msgs).toHaveLength(1)
|
|
19
|
+
expect(msgs[0]!.content).toBe(`Describe the project\n${JSON_INSTRUCTION}`)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('leaves the prompt alone when JSON is already mentioned, in any casing', () => {
|
|
23
|
+
const msgs: MessageFieldWithRole[] = [{ role: 'user', content: 'Answer as JSON please' }]
|
|
24
|
+
ensureJsonMention(msgs)
|
|
25
|
+
expect(msgs[0]!.content).toBe('Answer as JSON please')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('finds the mention inside structured content blocks', () => {
|
|
29
|
+
const msgs = [{ role: 'user', content: [{ type: 'text', text: 'reply in json' }] }] as unknown as MessageFieldWithRole[]
|
|
30
|
+
ensureJsonMention(msgs)
|
|
31
|
+
expect(msgs).toHaveLength(1)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('pushes a new message when the last one cannot be appended to', () => {
|
|
35
|
+
const msgs = [{ role: 'user', content: [{ type: 'text', text: 'look at this' }] }] as unknown as MessageFieldWithRole[]
|
|
36
|
+
ensureJsonMention(msgs)
|
|
37
|
+
expect(msgs).toHaveLength(2)
|
|
38
|
+
expect(msgs[1]!.content).toBe(JSON_INSTRUCTION)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('utils/prompt — thinking suppression', () => {
|
|
43
|
+
test('injects the soft switch only when the config asks for it', () => {
|
|
44
|
+
const off: MessageFieldWithRole[] = [{ role: 'user', content: 'hi' }]
|
|
45
|
+
applyNoThink(off, undefined)
|
|
46
|
+
expect(off[0]!.content).toBe('hi')
|
|
47
|
+
|
|
48
|
+
const on: MessageFieldWithRole[] = [{ role: 'user', content: 'hi' }]
|
|
49
|
+
applyNoThink(on, true)
|
|
50
|
+
expect(on[0]!.content).toBe(`hi\n${NO_THINK_DIRECTIVE}`)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('is idempotent', () => {
|
|
54
|
+
const msgs: MessageFieldWithRole[] = [{ role: 'user', content: 'hi' }]
|
|
55
|
+
applyNoThink(msgs, true)
|
|
56
|
+
applyNoThink(msgs, true)
|
|
57
|
+
expect(msgs[0]!.content).toBe(`hi\n${NO_THINK_DIRECTIVE}`)
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('utils/schema — tool naming and unwrapping', () => {
|
|
62
|
+
test('sanitises a schema title into a provider-acceptable tool name', () => {
|
|
63
|
+
expect(toToolName('User Story')).toBe('User_Story')
|
|
64
|
+
expect(toToolName('spec.v2/final')).toBe('spec_v2_final')
|
|
65
|
+
expect(toToolName('__weird__')).toBe('weird')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('falls back to the default name when nothing usable is present', () => {
|
|
69
|
+
expect(toToolName(undefined)).toBe('extract')
|
|
70
|
+
expect(toToolName('!!!')).toBe('extract')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('unwraps a named envelope, and passes anything else through', () => {
|
|
74
|
+
expect(unwrapNamed({ spec: { a: 1 } }, 'spec')).toEqual({ a: 1 })
|
|
75
|
+
expect(unwrapNamed({ a: 1 }, 'spec')).toEqual({ a: 1 })
|
|
76
|
+
expect(unwrapNamed({ a: 1 }, undefined)).toEqual({ a: 1 })
|
|
77
|
+
expect(unwrapNamed('plain' as unknown as object, 'spec')).toBe('plain' as unknown as object)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('utils/stream — idle deadline and duplicate-final-chunk dedup', () => {
|
|
82
|
+
const chunks = (...items: unknown[]): AsyncIterable<unknown> => ({
|
|
83
|
+
async *[Symbol.asyncIterator]() {
|
|
84
|
+
for (const item of items) yield item
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const collect = async (stream: AsyncGenerator<unknown>): Promise<unknown[]> => {
|
|
89
|
+
const out: unknown[] = []
|
|
90
|
+
for await (const chunk of stream) out.push(chunk)
|
|
91
|
+
return out
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
test('yields every chunk of a well-behaved stream', async () => {
|
|
95
|
+
const out = await collect(streamWithDeadline(async () => chunks({ a: 1 }, { a: 2 }), 1000))
|
|
96
|
+
expect(out).toEqual([{ a: 1 }, { a: 2 }])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// Some providers send the final SSE data event twice; concatenating it doubles every
|
|
100
|
+
// string field and corrupts accumulated tool-call arguments.
|
|
101
|
+
test('stops at the first chunk carrying a finish_reason', async () => {
|
|
102
|
+
const final = { response_metadata: { finish_reason: 'stop' } }
|
|
103
|
+
const out = await collect(streamWithDeadline(async () => chunks({ a: 1 }, final, final), 1000))
|
|
104
|
+
expect(out).toEqual([{ a: 1 }, final])
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('reads the finish reason out of a combined structured chunk too', () => {
|
|
108
|
+
expect(getChunkFinishReason({ raw: { response_metadata: { finish_reason: 'length' } } })).toBe('length')
|
|
109
|
+
expect(getChunkFinishReason({})).toBeUndefined()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('an empty finish_reason does not end the stream early', async () => {
|
|
113
|
+
const out = await collect(streamWithDeadline(
|
|
114
|
+
async () => chunks({ response_metadata: { finish_reason: '' } }, { a: 2 }), 1000
|
|
115
|
+
))
|
|
116
|
+
expect(out).toHaveLength(2)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('a provider that accepts the request and then goes silent is aborted, not awaited forever', async () => {
|
|
120
|
+
const stalled = (signal: AbortSignal): Promise<AsyncIterable<unknown>> => Promise.resolve({
|
|
121
|
+
async *[Symbol.asyncIterator]() {
|
|
122
|
+
yield { a: 1 }
|
|
123
|
+
await new Promise<void>((resolve, reject) => {
|
|
124
|
+
signal.addEventListener('abort', () => reject(new Error('aborted')))
|
|
125
|
+
})
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const started = Date.now()
|
|
130
|
+
await expect(collect(streamWithDeadline(stalled, 60))).rejects.toThrow(/stream-stalled/)
|
|
131
|
+
// The deadline is per-token, so the first chunk re-arms it: expect roughly one window.
|
|
132
|
+
expect(Date.now() - started).toBeLessThan(2000)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('an error from the provider itself is surfaced unchanged', async () => {
|
|
136
|
+
const failing = async (): Promise<AsyncIterable<unknown>> => {
|
|
137
|
+
throw new Error('401 unauthorized')
|
|
138
|
+
}
|
|
139
|
+
await expect(collect(streamWithDeadline(failing, 1000))).rejects.toThrow('401 unauthorized')
|
|
140
|
+
})
|
|
141
|
+
})
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { Ajv } from 'ajv'
|
|
3
|
+
import type { JSONSchemaType } from 'ajv'
|
|
4
|
+
import { DEFAULT_EFFORT, makeExecutionService, makeLlmModel, makeLlmService } from '@owlmeans/llm'
|
|
5
|
+
import type { HelperExecution, LlmModel } from '@owlmeans/llm'
|
|
6
|
+
import {
|
|
7
|
+
anthropicConfigs, gates, openRouterConfigs, recordingSpectator, Role, SpecificationSchema,
|
|
8
|
+
} from './context.js'
|
|
9
|
+
import type { ModelConfig } from '@owlmeans/llm'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Integration specs against a real provider. They reproduce the LIVE call path a consumer
|
|
13
|
+
* uses — resolve a model through the execution service by role, wrap it with a spectator,
|
|
14
|
+
* then ask/talk/invoke/request — rather than a special test-only arrangement.
|
|
15
|
+
*
|
|
16
|
+
* Gated on credentials: with none configured the suite reports the reason and skips.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
interface Specification {
|
|
20
|
+
title: string
|
|
21
|
+
summary: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const validate = new Ajv({ strict: false }).compile(SpecificationSchema as unknown as JSONSchemaType<Specification>)
|
|
25
|
+
|
|
26
|
+
const PROMPT = 'Produce a 50 word specification for a to-do list web application.'
|
|
27
|
+
|
|
28
|
+
/** Build the same three-layer arrangement a consumer builds, and return a model helper. */
|
|
29
|
+
const liveModel = (configs: () => ModelConfig[], alias: string): {
|
|
30
|
+
model: LlmModel, spectator: ReturnType<typeof recordingSpectator>, helper: HelperExecution
|
|
31
|
+
} => {
|
|
32
|
+
const llm = makeLlmService({ models: configs }, `${alias}-llm`)
|
|
33
|
+
const executions = makeExecutionService(`${alias}-exec`)
|
|
34
|
+
const root = executions.root({
|
|
35
|
+
models: () => llm,
|
|
36
|
+
policy: { effort: DEFAULT_EFFORT },
|
|
37
|
+
purpose: { type: 'integration-spec' },
|
|
38
|
+
outputErrors: true,
|
|
39
|
+
})
|
|
40
|
+
const helper = executions.forHelper(root, { role: Role.Analyst, dedication: 'specification' })
|
|
41
|
+
const spectator = recordingSpectator()
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
helper,
|
|
45
|
+
spectator,
|
|
46
|
+
model: makeLlmModel({
|
|
47
|
+
model: helper.model,
|
|
48
|
+
purpose: helper.purpose,
|
|
49
|
+
outputErrors: true,
|
|
50
|
+
retries: 3,
|
|
51
|
+
}, spectator),
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const suite = (name: string, configs: () => ModelConfig[], skip: string | null) => {
|
|
56
|
+
describe(`@owlmeans/llm — live model (${name})`, () => {
|
|
57
|
+
test.skipIf(skip != null)(`ask returns text${skip != null ? ` — SKIPPED: ${skip}` : ''}`, async () => {
|
|
58
|
+
const { model, spectator } = liveModel(configs, `${name}-ask`)
|
|
59
|
+
const result = await model.ask(PROMPT, { action: 'spec-ask' })
|
|
60
|
+
expect(typeof result).toBe('string')
|
|
61
|
+
expect(result.trim().length).toBeGreaterThan(0)
|
|
62
|
+
// Every call is observable: the prompt and the completion reach the spectator.
|
|
63
|
+
expect(spectator.entries).toHaveLength(1)
|
|
64
|
+
expect(spectator.entries[0]!.action).toBe('spec-ask')
|
|
65
|
+
expect(spectator.entries[0]!.messages.length).toBeGreaterThanOrEqual(2)
|
|
66
|
+
}, 480000)
|
|
67
|
+
|
|
68
|
+
test.skipIf(skip != null)('talk returns the raw message', async () => {
|
|
69
|
+
const { model } = liveModel(configs, `${name}-talk`)
|
|
70
|
+
const result = await model.talk(PROMPT, { action: 'spec-talk' })
|
|
71
|
+
expect(typeof result).toBe('object')
|
|
72
|
+
expect(typeof result.content).toBe('string')
|
|
73
|
+
}, 480000)
|
|
74
|
+
|
|
75
|
+
test.skipIf(skip != null)('invoke returns a schema-validated object', async () => {
|
|
76
|
+
const { model } = liveModel(configs, `${name}-invoke`)
|
|
77
|
+
const result = await model.invoke<Specification>(
|
|
78
|
+
PROMPT, SpecificationSchema as unknown as JSONSchemaType<Specification>,
|
|
79
|
+
{ action: 'spec-invoke' }
|
|
80
|
+
)
|
|
81
|
+
expect(validate(result)).toBe(true)
|
|
82
|
+
expect(result.title).toBeString()
|
|
83
|
+
}, 480000)
|
|
84
|
+
|
|
85
|
+
test.skipIf(skip != null)('request returns a message whose content is the validated JSON', async () => {
|
|
86
|
+
const { model } = liveModel(configs, `${name}-request`)
|
|
87
|
+
const result = await model.request<Specification>(
|
|
88
|
+
PROMPT, SpecificationSchema as unknown as JSONSchemaType<Specification>,
|
|
89
|
+
{ action: 'spec-request' }
|
|
90
|
+
)
|
|
91
|
+
expect(typeof result.content).toBe('string')
|
|
92
|
+
expect(validate(JSON.parse(result.content as string))).toBe(true)
|
|
93
|
+
}, 480000)
|
|
94
|
+
|
|
95
|
+
test.skipIf(skip != null)('a filter that rejects the output is retried, then surfaced', async () => {
|
|
96
|
+
const { model } = liveModel(configs, `${name}-filter`)
|
|
97
|
+
let seen = 0
|
|
98
|
+
await expect(model.ask(PROMPT, {
|
|
99
|
+
action: 'spec-filter',
|
|
100
|
+
filter: async () => { seen += 1; return null },
|
|
101
|
+
})).rejects.toThrow()
|
|
102
|
+
expect(seen).toBeGreaterThan(1)
|
|
103
|
+
}, 480000)
|
|
104
|
+
|
|
105
|
+
test.skipIf(skip != null)('a ref receives the message and the spectator entry', async () => {
|
|
106
|
+
const { model } = liveModel(configs, `${name}-ref`)
|
|
107
|
+
const ref: Parameters<LlmModel['ask']>[1]['ref'] = {}
|
|
108
|
+
await model.ask(PROMPT, { action: 'spec-ref', ref })
|
|
109
|
+
expect(ref.value).toBeDefined()
|
|
110
|
+
expect(ref.spectatorEntry?.action).toBe('spec-ref')
|
|
111
|
+
}, 480000)
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
suite('openrouter', openRouterConfigs, 'skip' in gates.openrouter ? gates.openrouter.reason : null)
|
|
116
|
+
suite('anthropic', anthropicConfigs, 'skip' in gates.anthropic ? gates.anthropic.reason : null)
|