@namzu/sdk 5.1.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +465 -0
- package/dist/agents/__tests__/run-agent-forwarding.test.d.ts +2 -0
- package/dist/agents/__tests__/run-agent-forwarding.test.d.ts.map +1 -0
- package/dist/agents/__tests__/run-agent-forwarding.test.js +76 -0
- package/dist/agents/__tests__/run-agent-forwarding.test.js.map +1 -0
- package/dist/agents/__tests__/run-completed-stop-reason.test.d.ts +2 -0
- package/dist/agents/__tests__/run-completed-stop-reason.test.d.ts.map +1 -0
- package/dist/agents/__tests__/run-completed-stop-reason.test.js +50 -0
- package/dist/agents/__tests__/run-completed-stop-reason.test.js.map +1 -0
- package/dist/agents/index.d.ts +1 -0
- package/dist/agents/index.d.ts.map +1 -1
- package/dist/agents/index.js +4 -0
- package/dist/agents/index.js.map +1 -1
- package/dist/agents/runAgent.d.ts +34 -0
- package/dist/agents/runAgent.d.ts.map +1 -1
- package/dist/agents/runAgent.js +5 -3
- package/dist/agents/runAgent.js.map +1 -1
- package/dist/config/runtime.d.ts +30 -30
- package/dist/constants/tools/__tests__/dangerous-patterns.test.d.ts +2 -0
- package/dist/constants/tools/__tests__/dangerous-patterns.test.d.ts.map +1 -0
- package/dist/constants/tools/__tests__/dangerous-patterns.test.js +54 -0
- package/dist/constants/tools/__tests__/dangerous-patterns.test.js.map +1 -0
- package/dist/constants/tools/index.d.ts.map +1 -1
- package/dist/constants/tools/index.js +28 -1
- package/dist/constants/tools/index.js.map +1 -1
- package/dist/contracts/a2a.d.ts +44 -44
- package/dist/directory/__tests__/derive.test.d.ts +2 -0
- package/dist/directory/__tests__/derive.test.d.ts.map +1 -0
- package/dist/directory/__tests__/derive.test.js +111 -0
- package/dist/directory/__tests__/derive.test.js.map +1 -0
- package/dist/directory/__tests__/load-diagnostics.test.d.ts +2 -0
- package/dist/directory/__tests__/load-diagnostics.test.d.ts.map +1 -0
- package/dist/directory/__tests__/load-diagnostics.test.js +106 -0
- package/dist/directory/__tests__/load-diagnostics.test.js.map +1 -0
- package/dist/directory/__tests__/load-directory.test.d.ts +2 -0
- package/dist/directory/__tests__/load-directory.test.d.ts.map +1 -0
- package/dist/directory/__tests__/load-directory.test.js +196 -0
- package/dist/directory/__tests__/load-directory.test.js.map +1 -0
- package/dist/directory/__tests__/subagents.test.d.ts +2 -0
- package/dist/directory/__tests__/subagents.test.d.ts.map +1 -0
- package/dist/directory/__tests__/subagents.test.js +117 -0
- package/dist/directory/__tests__/subagents.test.js.map +1 -0
- package/dist/directory/__tests__/supervisor-budget.test.d.ts +2 -0
- package/dist/directory/__tests__/supervisor-budget.test.d.ts.map +1 -0
- package/dist/directory/__tests__/supervisor-budget.test.js +97 -0
- package/dist/directory/__tests__/supervisor-budget.test.js.map +1 -0
- package/dist/directory/derive-supervisor.d.ts +58 -0
- package/dist/directory/derive-supervisor.d.ts.map +1 -0
- package/dist/directory/derive-supervisor.js +88 -0
- package/dist/directory/derive-supervisor.js.map +1 -0
- package/dist/directory/derive.d.ts +12 -0
- package/dist/directory/derive.d.ts.map +1 -0
- package/dist/directory/derive.js +58 -0
- package/dist/directory/derive.js.map +1 -0
- package/dist/directory/index.d.ts +7 -0
- package/dist/directory/index.d.ts.map +1 -0
- package/dist/directory/index.js +5 -0
- package/dist/directory/index.js.map +1 -0
- package/dist/directory/load.d.ts +14 -0
- package/dist/directory/load.d.ts.map +1 -0
- package/dist/directory/load.js +488 -0
- package/dist/directory/load.js.map +1 -0
- package/dist/directory/scan.d.ts +36 -0
- package/dist/directory/scan.d.ts.map +1 -0
- package/dist/directory/scan.js +136 -0
- package/dist/directory/scan.js.map +1 -0
- package/dist/directory/types.d.ts +215 -0
- package/dist/directory/types.d.ts.map +1 -0
- package/dist/directory/types.js +8 -0
- package/dist/directory/types.js.map +1 -0
- package/dist/plugin/__tests__/path-containment.test.d.ts +2 -0
- package/dist/plugin/__tests__/path-containment.test.d.ts.map +1 -0
- package/dist/plugin/__tests__/path-containment.test.js +91 -0
- package/dist/plugin/__tests__/path-containment.test.js.map +1 -0
- package/dist/plugin/lifecycle.d.ts.map +1 -1
- package/dist/plugin/lifecycle.js +3 -3
- package/dist/plugin/lifecycle.js.map +1 -1
- package/dist/plugin/loader.d.ts.map +1 -1
- package/dist/plugin/loader.js +12 -2
- package/dist/plugin/loader.js.map +1 -1
- package/dist/provider/__tests__/model-version.test.d.ts +2 -0
- package/dist/provider/__tests__/model-version.test.d.ts.map +1 -0
- package/dist/provider/__tests__/model-version.test.js +80 -0
- package/dist/provider/__tests__/model-version.test.js.map +1 -0
- package/dist/provider/__tests__/strict-schema.test.d.ts +2 -0
- package/dist/provider/__tests__/strict-schema.test.d.ts.map +1 -0
- package/dist/provider/__tests__/strict-schema.test.js +157 -0
- package/dist/provider/__tests__/strict-schema.test.js.map +1 -0
- package/dist/provider/__tests__/vendor-detail.test.d.ts +2 -0
- package/dist/provider/__tests__/vendor-detail.test.d.ts.map +1 -0
- package/dist/provider/__tests__/vendor-detail.test.js +89 -0
- package/dist/provider/__tests__/vendor-detail.test.js.map +1 -0
- package/dist/provider/errors.d.ts +38 -5
- package/dist/provider/errors.d.ts.map +1 -1
- package/dist/provider/errors.js +107 -5
- package/dist/provider/errors.js.map +1 -1
- package/dist/provider/model-version.d.ts +60 -0
- package/dist/provider/model-version.d.ts.map +1 -0
- package/dist/provider/model-version.js +64 -0
- package/dist/provider/model-version.js.map +1 -0
- package/dist/provider/strict-schema.d.ts +49 -0
- package/dist/provider/strict-schema.d.ts.map +1 -0
- package/dist/provider/strict-schema.js +168 -0
- package/dist/provider/strict-schema.js.map +1 -0
- package/dist/public-runtime.d.ts +9 -1
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +27 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/public-types.d.ts +2 -0
- package/dist/public-types.d.ts.map +1 -1
- package/dist/registry/tool/__tests__/dialect.test.d.ts +2 -0
- package/dist/registry/tool/__tests__/dialect.test.d.ts.map +1 -0
- package/dist/registry/tool/__tests__/dialect.test.js +143 -0
- package/dist/registry/tool/__tests__/dialect.test.js.map +1 -0
- package/dist/registry/tool/__tests__/strict-registration.test.d.ts +2 -0
- package/dist/registry/tool/__tests__/strict-registration.test.d.ts.map +1 -0
- package/dist/registry/tool/__tests__/strict-registration.test.js +82 -0
- package/dist/registry/tool/__tests__/strict-registration.test.js.map +1 -0
- package/dist/registry/tool/dialect.d.ts +50 -0
- package/dist/registry/tool/dialect.d.ts.map +1 -0
- package/dist/registry/tool/dialect.js +131 -0
- package/dist/registry/tool/dialect.js.map +1 -0
- package/dist/registry/tool/execute.d.ts.map +1 -1
- package/dist/registry/tool/execute.js +16 -0
- package/dist/registry/tool/execute.js.map +1 -1
- package/dist/registry/toolset/catalog.d.ts.map +1 -1
- package/dist/registry/toolset/catalog.js +10 -5
- package/dist/registry/toolset/catalog.js.map +1 -1
- package/dist/runtime/query/__tests__/stream-recovery.test.js +6 -0
- package/dist/runtime/query/__tests__/stream-recovery.test.js.map +1 -1
- package/dist/runtime/query/result.d.ts.map +1 -1
- package/dist/runtime/query/result.js +11 -0
- package/dist/runtime/query/result.js.map +1 -1
- package/dist/tools/builtins/__tests__/edit-insert-reachable.test.js +7 -1
- package/dist/tools/builtins/__tests__/edit-insert-reachable.test.js.map +1 -1
- package/dist/tools/builtins/__tests__/edit.test.js +1 -1
- package/dist/tools/builtins/__tests__/edit.test.js.map +1 -1
- package/dist/tools/builtins/edit.d.ts.map +1 -1
- package/dist/tools/builtins/edit.js +20 -2
- package/dist/tools/builtins/edit.js.map +1 -1
- package/dist/types/plugin/index.d.ts +4 -4
- package/dist/types/provider/error.d.ts +20 -4
- package/dist/types/provider/error.d.ts.map +1 -1
- package/dist/types/run/events.d.ts +17 -2
- package/dist/types/run/events.d.ts.map +1 -1
- package/dist/types/run/events.js.map +1 -1
- package/dist/types/verification/index.d.ts +6 -6
- package/package.json +1 -1
- package/src/agents/__tests__/run-agent-forwarding.test.ts +85 -0
- package/src/agents/__tests__/run-completed-stop-reason.test.ts +59 -0
- package/src/agents/index.ts +8 -0
- package/src/agents/runAgent.ts +30 -4
- package/src/constants/tools/__tests__/dangerous-patterns.test.ts +62 -0
- package/src/constants/tools/index.ts +28 -1
- package/src/directory/__tests__/derive.test.ts +135 -0
- package/src/directory/__tests__/load-diagnostics.test.ts +126 -0
- package/src/directory/__tests__/load-directory.test.ts +243 -0
- package/src/directory/__tests__/subagents.test.ts +155 -0
- package/src/directory/__tests__/supervisor-budget.test.ts +117 -0
- package/src/directory/derive-supervisor.ts +149 -0
- package/src/directory/derive.ts +70 -0
- package/src/directory/index.ts +27 -0
- package/src/directory/load.ts +556 -0
- package/src/directory/scan.ts +163 -0
- package/src/directory/types.ts +254 -0
- package/src/plugin/__tests__/path-containment.test.ts +107 -0
- package/src/plugin/lifecycle.ts +3 -3
- package/src/plugin/loader.ts +12 -2
- package/src/provider/__tests__/model-version.test.ts +93 -0
- package/src/provider/__tests__/strict-schema.test.ts +189 -0
- package/src/provider/__tests__/vendor-detail.test.ts +107 -0
- package/src/provider/errors.ts +106 -5
- package/src/provider/model-version.ts +98 -0
- package/src/provider/strict-schema.ts +182 -0
- package/src/public-runtime.ts +38 -0
- package/src/public-types.ts +8 -0
- package/src/registry/tool/__tests__/dialect.test.ts +197 -0
- package/src/registry/tool/__tests__/strict-registration.test.ts +115 -0
- package/src/registry/tool/dialect.ts +136 -0
- package/src/registry/tool/execute.ts +16 -0
- package/src/registry/toolset/catalog.ts +10 -5
- package/src/runtime/query/__tests__/stream-recovery.test.ts +6 -0
- package/src/runtime/query/result.ts +11 -0
- package/src/tools/builtins/__tests__/edit-insert-reachable.test.ts +8 -2
- package/src/tools/builtins/__tests__/edit.test.ts +1 -1
- package/src/tools/builtins/edit.ts +20 -2
- package/src/types/provider/error.ts +20 -4
- package/src/types/run/events.ts +15 -2
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { getBuiltinTools } from '../../tools/builtins/index.js'
|
|
4
|
+
import { assertStrictSchema, findStrictSchemaViolations } from '../strict-schema.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Strict tool input validates against a SUBSET of JSON Schema, and a keyword
|
|
8
|
+
* outside that subset is not degraded — the vendor rejects the entire request,
|
|
9
|
+
* so one unexpressible field in one tool takes down every tool in the call and
|
|
10
|
+
* the turn dies before producing a token.
|
|
11
|
+
*
|
|
12
|
+
* That shipped. The edit tool declared its integer-or-`"end"` field with
|
|
13
|
+
* `oneOf`, which is outside the subset while the equivalent `anyOf` is inside
|
|
14
|
+
* it, and the driver marked the tool strict without asking whether the schema
|
|
15
|
+
* it was vouching for could be said in that dialect.
|
|
16
|
+
*
|
|
17
|
+
* Measured against the live API:
|
|
18
|
+
*
|
|
19
|
+
* | body | result |
|
|
20
|
+
* |------------------------|----------|
|
|
21
|
+
* | strict: true + oneOf | 400 |
|
|
22
|
+
* | strict: false + oneOf | accepted |
|
|
23
|
+
* | strict: true + anyOf | accepted |
|
|
24
|
+
*
|
|
25
|
+
* The middle row is why nothing caught it. Neither half is wrong alone — the
|
|
26
|
+
* schema is valid JSON Schema, and turning strict on is correct policy — so no
|
|
27
|
+
* test of either one fails. Only the pairing does, and the pairing had no
|
|
28
|
+
* owner. The sweep below is that owner.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
describe('every tool that asks for strict validation can be expressed strictly', () => {
|
|
32
|
+
// The regression test that matters. Checking only the tool that broke
|
|
33
|
+
// would leave the next one to be found in production, which is how this
|
|
34
|
+
// one was found.
|
|
35
|
+
it.each(
|
|
36
|
+
getBuiltinTools()
|
|
37
|
+
.filter((t) => t.enforceModelInput)
|
|
38
|
+
.map((t) => [t.name, t] as const),
|
|
39
|
+
)('%s', (name, tool) => {
|
|
40
|
+
const violations = findStrictSchemaViolations(tool.modelInputSchema)
|
|
41
|
+
expect(violations, violations.map((v) => `${name}.${v.path}: ${v.remedy}`).join('\n')).toEqual(
|
|
42
|
+
[],
|
|
43
|
+
)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('found at least one tool to check', () => {
|
|
47
|
+
// Guards the sweep itself: a filter that matches nothing passes
|
|
48
|
+
// vacuously, and a rename of `enforceModelInput` would silently turn
|
|
49
|
+
// this whole file into a no-op.
|
|
50
|
+
expect(getBuiltinTools().filter((t) => t.enforceModelInput).length).toBeGreaterThan(0)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('the violation report names the exact path', () => {
|
|
55
|
+
it('points at the keyword inside a nested property', () => {
|
|
56
|
+
const schema = {
|
|
57
|
+
type: 'object',
|
|
58
|
+
properties: {
|
|
59
|
+
insertLine: { oneOf: [{ type: 'integer' }, { const: 'end' }] },
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
expect(findStrictSchemaViolations(schema)).toEqual([
|
|
64
|
+
{
|
|
65
|
+
path: 'properties.insertLine.oneOf',
|
|
66
|
+
keyword: 'oneOf',
|
|
67
|
+
remedy: 'use `anyOf` — for disjoint branches the two are equivalent',
|
|
68
|
+
},
|
|
69
|
+
])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('accepts the anyOf spelling of the same union', () => {
|
|
73
|
+
const schema = {
|
|
74
|
+
type: 'object',
|
|
75
|
+
properties: {
|
|
76
|
+
insertLine: { anyOf: [{ type: 'integer' }, { const: 'end' }] },
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
expect(findStrictSchemaViolations(schema)).toEqual([])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('reports the bounds the wire refuses, and only those', () => {
|
|
84
|
+
// Measured against the live API rather than read off a page. The first
|
|
85
|
+
// version of this list was derived from documentation and was wrong in
|
|
86
|
+
// both directions: it refused `maxLength`, which the wire accepts, and
|
|
87
|
+
// permitted `prefixItems`, which it rejects.
|
|
88
|
+
const schema = {
|
|
89
|
+
type: 'object',
|
|
90
|
+
properties: {
|
|
91
|
+
n: { type: 'integer', minimum: 0 },
|
|
92
|
+
s: { type: 'string', maxLength: 10 },
|
|
93
|
+
a: { type: 'array', items: { type: 'string' }, maxItems: 3, minItems: 1 },
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
expect(
|
|
98
|
+
findStrictSchemaViolations(schema)
|
|
99
|
+
.map((v) => v.keyword)
|
|
100
|
+
.sort(),
|
|
101
|
+
).toEqual(['maxItems', 'minimum'])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('leaves string length alone, because strict accepts it', () => {
|
|
105
|
+
// The false positive that would have refused tools which work.
|
|
106
|
+
expect(
|
|
107
|
+
findStrictSchemaViolations({ s: { type: 'string', minLength: 1, maxLength: 9 } }),
|
|
108
|
+
).toEqual([])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('catches a tuple in either spelling, because strict admits neither', () => {
|
|
112
|
+
// The interaction worth pinning, and the one a `prefixItems` entry alone
|
|
113
|
+
// got wrong. This check runs at REGISTRATION, on the schema as rendered
|
|
114
|
+
// — draft-07, where a tuple is `items: [a, b]` — while the wire sees the
|
|
115
|
+
// `prefixItems` the driver converts it to. So denying only `prefixItems`
|
|
116
|
+
// was a guard that could not fire on the path that produces tuples.
|
|
117
|
+
//
|
|
118
|
+
// Measured, strict rejects both, which is why a tool that is both strict
|
|
119
|
+
// and tuple-shaped cannot be expressed at all. Converting it only
|
|
120
|
+
// changes which error comes back.
|
|
121
|
+
for (const items of [
|
|
122
|
+
{ prefixItems: [{ type: 'integer' }, { type: 'integer' }] },
|
|
123
|
+
{ items: [{ type: 'integer' }, { type: 'integer' }] },
|
|
124
|
+
]) {
|
|
125
|
+
const violations = findStrictSchemaViolations({
|
|
126
|
+
properties: { range: { type: 'array', ...items } },
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
expect(violations, JSON.stringify(items)).toHaveLength(1)
|
|
130
|
+
expect(violations[0]?.remedy).toContain('tuple cannot be expressed')
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('leaves an ordinary array alone, where `items` is one schema', () => {
|
|
135
|
+
// The false positive the tuple rule must not become: `items` is the
|
|
136
|
+
// normal spelling for a homogeneous array and strict accepts it. Only
|
|
137
|
+
// the array-of-schemas form is a tuple.
|
|
138
|
+
expect(findStrictSchemaViolations({ type: 'array', items: { type: 'string' } })).toEqual([])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('admits minItems at 0 or 1 and refuses it above, as the wire does', () => {
|
|
142
|
+
// A blanket denial here was a false positive with a real cost: it
|
|
143
|
+
// refuses `z.array(...).nonempty()`, which renders `minItems: 1` and
|
|
144
|
+
// which the wire accepts. The constraint is on the VALUE, and the
|
|
145
|
+
// vendor's error says so — "'minItems' values other than 0 or 1 are not
|
|
146
|
+
// supported".
|
|
147
|
+
expect(findStrictSchemaViolations({ type: 'array', minItems: 0 })).toEqual([])
|
|
148
|
+
expect(findStrictSchemaViolations({ type: 'array', minItems: 1 })).toEqual([])
|
|
149
|
+
|
|
150
|
+
const violations = findStrictSchemaViolations({ type: 'array', minItems: 2 })
|
|
151
|
+
expect(violations).toHaveLength(1)
|
|
152
|
+
expect(violations[0]?.keyword).toBe('minItems')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('admits additionalProperties only as false', () => {
|
|
156
|
+
expect(findStrictSchemaViolations({ additionalProperties: false })).toEqual([])
|
|
157
|
+
expect(findStrictSchemaViolations({ additionalProperties: { type: 'string' } })).toHaveLength(1)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('walks into arrays of subschemas', () => {
|
|
161
|
+
const schema = { anyOf: [{ type: 'string' }, { not: { type: 'null' } }] }
|
|
162
|
+
|
|
163
|
+
expect(findStrictSchemaViolations(schema)[0]?.path).toBe('anyOf[1].not')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('leaves ordinary annotations alone', () => {
|
|
167
|
+
const schema = {
|
|
168
|
+
type: 'object',
|
|
169
|
+
description: 'a tool',
|
|
170
|
+
properties: { a: { type: 'string', description: 'x', enum: ['y'] } },
|
|
171
|
+
required: ['a'],
|
|
172
|
+
additionalProperties: false,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
expect(findStrictSchemaViolations(schema)).toEqual([])
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
describe('assertStrictSchema refuses rather than letting the request go', () => {
|
|
180
|
+
it('throws naming the tool, the path and the fix', () => {
|
|
181
|
+
expect(() => assertStrictSchema('edit', { properties: { insertLine: { oneOf: [] } } })).toThrow(
|
|
182
|
+
/edit\.properties\.insertLine\.oneOf/,
|
|
183
|
+
)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('says nothing about a schema that is expressible', () => {
|
|
187
|
+
expect(() => assertStrictSchema('edit', { type: 'object' })).not.toThrow()
|
|
188
|
+
})
|
|
189
|
+
})
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { providerHttpError, redactSecrets, vendorDetail } from '../errors.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The provider's own account of what was wrong, kept — and scrubbed.
|
|
7
|
+
*
|
|
8
|
+
* `ProviderRequestErrorInit` declared `detail` from the beginning and the
|
|
9
|
+
* constructor never read it, so the field existed and carried nothing. The
|
|
10
|
+
* body was read to classify and then dropped, deliberately, because an error
|
|
11
|
+
* body can echo a request and a request can carry a key.
|
|
12
|
+
*
|
|
13
|
+
* The cost of that trade showed up in production: the wire had been saying
|
|
14
|
+
* `tools.0.custom.input_schema: … must match JSON Schema draft 2020-12` and
|
|
15
|
+
* the SDK deleted the sentence, so diagnosing it took seven eliminated
|
|
16
|
+
* hypotheses and a day of downtime. Keeping the sentence and scrubbing the
|
|
17
|
+
* credential shapes is the trade that was actually available.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
describe('the sentence that names the broken field survives', () => {
|
|
21
|
+
it('lifts the structured message out of a vendor body', () => {
|
|
22
|
+
const body = JSON.stringify({
|
|
23
|
+
type: 'error',
|
|
24
|
+
error: {
|
|
25
|
+
type: 'invalid_request_error',
|
|
26
|
+
message:
|
|
27
|
+
'tools.0.custom.input_schema: JSON schema is invalid. It must match JSON Schema draft 2020-12',
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
expect(vendorDetail(body)).toBe(
|
|
32
|
+
'tools.0.custom.input_schema: JSON schema is invalid. It must match JSON Schema draft 2020-12',
|
|
33
|
+
)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('reaches the error a caller actually catches', () => {
|
|
37
|
+
const err = providerHttpError({
|
|
38
|
+
providerId: 'anthropic',
|
|
39
|
+
status: 400,
|
|
40
|
+
body: JSON.stringify({ error: { message: "Schema type 'oneOf' is not supported" } }),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
expect(err.detail).toContain('oneOf')
|
|
44
|
+
// …and the message too, so a log line that prints only the message is
|
|
45
|
+
// still enough to act on.
|
|
46
|
+
expect(err.message).toContain('oneOf')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('falls back to the raw text when the body is not JSON', () => {
|
|
50
|
+
expect(vendorDetail('upstream connect error, transport failure')).toBe(
|
|
51
|
+
'upstream connect error, transport failure',
|
|
52
|
+
)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('says nothing rather than something empty', () => {
|
|
56
|
+
expect(vendorDetail(undefined)).toBeUndefined()
|
|
57
|
+
expect(vendorDetail(null)).toBeUndefined()
|
|
58
|
+
expect(vendorDetail(' ')).toBeUndefined()
|
|
59
|
+
expect(vendorDetail({})).toBeUndefined()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('truncates a body that is not a sentence', () => {
|
|
63
|
+
const detail = vendorDetail('x'.repeat(5_000))
|
|
64
|
+
expect(detail?.length).toBeLessThanOrEqual(401)
|
|
65
|
+
expect(detail?.endsWith('…')).toBe(true)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('a credential never rides along', () => {
|
|
70
|
+
it.each([
|
|
71
|
+
['sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv', 'anthropic-style key'],
|
|
72
|
+
['npm_aBcDeFgHiJkLmNoPqRsTuVwXyZ012345', 'npm token'],
|
|
73
|
+
['ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZ012345', 'github token'],
|
|
74
|
+
['AKIAIOSFODNN7EXAMPLE', 'aws access key id'],
|
|
75
|
+
])('scrubs %s (%s)', (secret) => {
|
|
76
|
+
const scrubbed = redactSecrets(`upstream rejected token ${secret} for this request`)
|
|
77
|
+
expect(scrubbed).not.toContain(secret)
|
|
78
|
+
expect(scrubbed).toContain('[redacted]')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('scrubs a bearer header the vendor echoed back', () => {
|
|
82
|
+
const scrubbed = redactSecrets('bad header: Authorization: Bearer abcdef0123456789ABCDEF')
|
|
83
|
+
expect(scrubbed).not.toContain('abcdef0123456789ABCDEF')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('scrubs a credential-named JSON field without eating the rest', () => {
|
|
87
|
+
const scrubbed = redactSecrets('{"api_key":"sk-live-9999","model":"the-model-that-failed"}')
|
|
88
|
+
expect(scrubbed).not.toContain('sk-live-9999')
|
|
89
|
+
// The surrounding sentence is the whole point — scrubbing must not
|
|
90
|
+
// degrade into deleting the message.
|
|
91
|
+
expect(scrubbed).toContain('the-model-that-failed')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('scrubs on the real path, not only in the helper', () => {
|
|
95
|
+
const err = providerHttpError({
|
|
96
|
+
providerId: 'anthropic',
|
|
97
|
+
status: 401,
|
|
98
|
+
body: JSON.stringify({
|
|
99
|
+
error: { message: 'invalid key sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv supplied' },
|
|
100
|
+
}),
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
expect(err.detail).not.toContain('AbCdEfGhIjKlMnOpQrStUv')
|
|
104
|
+
expect(err.detail).toContain('[redacted]')
|
|
105
|
+
expect(err.message).not.toContain('AbCdEfGhIjKlMnOpQrStUv')
|
|
106
|
+
})
|
|
107
|
+
})
|
package/src/provider/errors.ts
CHANGED
|
@@ -11,11 +11,19 @@
|
|
|
11
11
|
*
|
|
12
12
|
* So the contract here is deliberately narrow:
|
|
13
13
|
*
|
|
14
|
-
* - the message is built from the STATUS LINE
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* survives every logger that serializes an error chain,
|
|
18
|
-
* point.
|
|
14
|
+
* - the message is built from the STATUS LINE, the classified `kind`, and the
|
|
15
|
+
* provider's own complaint in `detail` — truncated and scrubbed of anything
|
|
16
|
+
* credential-shaped. The raw body is never re-thrown and never attached as
|
|
17
|
+
* `cause`; a `cause` survives every logger that serializes an error chain,
|
|
18
|
+
* which defeats the point.
|
|
19
|
+
*
|
|
20
|
+
* The body used to be dropped entirely. That was over-corrected: a provider
|
|
21
|
+
* rejecting a request names the exact offending field, and deleting that
|
|
22
|
+
* sentence turned a one-line diagnosis into hypothesis elimination against a
|
|
23
|
+
* live API — once at the cost of a day of production downtime, while the
|
|
24
|
+
* wire had been saying `tools.0.custom.input_schema: … must match JSON
|
|
25
|
+
* Schema draft 2020-12` the entire time. Scrubbing what looks like a
|
|
26
|
+
* credential keeps the safety and returns the sentence.
|
|
19
27
|
* - `retryAfterMs` is DATA. Nothing in this module sleeps, backs off or
|
|
20
28
|
* retries. A retry loop inside a driver burns the run's wall clock and hides
|
|
21
29
|
* the failure from the layer that should decide.
|
|
@@ -50,6 +58,22 @@ export class ProviderRequestError extends Error {
|
|
|
50
58
|
public readonly providerId: string
|
|
51
59
|
public readonly status?: number
|
|
52
60
|
public readonly retryAfterMs?: number
|
|
61
|
+
/**
|
|
62
|
+
* What the provider said was wrong, truncated and redacted.
|
|
63
|
+
*
|
|
64
|
+
* `ProviderRequestErrorInit` has declared this field all along and the
|
|
65
|
+
* constructor never read it, so every caller that set it was writing to
|
|
66
|
+
* nothing. That is not a cosmetic gap: a provider rejecting a request
|
|
67
|
+
* usually names the exact offending field, and losing that sentence turns
|
|
68
|
+
* a one-line diagnosis into hypothesis elimination against a live API. It
|
|
69
|
+
* did — a tool schema in the wrong JSON Schema dialect cost a day of
|
|
70
|
+
* production downtime while the wire had been saying
|
|
71
|
+
* `tools.0.custom.input_schema: … must match JSON Schema draft 2020-12`
|
|
72
|
+
* the whole time.
|
|
73
|
+
*
|
|
74
|
+
* See {@link vendorDetail} for what is kept and what is scrubbed.
|
|
75
|
+
*/
|
|
76
|
+
public readonly detail?: string
|
|
53
77
|
|
|
54
78
|
constructor(init: ProviderRequestErrorInit) {
|
|
55
79
|
super(buildProviderErrorMessage(init))
|
|
@@ -58,7 +82,80 @@ export class ProviderRequestError extends Error {
|
|
|
58
82
|
this.providerId = init.providerId
|
|
59
83
|
if (init.status !== undefined) this.status = init.status
|
|
60
84
|
if (init.retryAfterMs !== undefined) this.retryAfterMs = init.retryAfterMs
|
|
85
|
+
if (init.detail !== undefined) this.detail = init.detail
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Longest detail worth carrying. A provider's complaint is a sentence. */
|
|
90
|
+
const DETAIL_MAX = 400
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Credential shapes to scrub before a provider's words are kept.
|
|
94
|
+
*
|
|
95
|
+
* The original decision to discard the body outright was not paranoia — an
|
|
96
|
+
* error body can echo the request, and a request can carry a key. The answer
|
|
97
|
+
* is to scrub what looks like a credential rather than to throw away the
|
|
98
|
+
* sentence that names the broken field.
|
|
99
|
+
*/
|
|
100
|
+
const SECRET_PATTERNS: readonly RegExp[] = [
|
|
101
|
+
/\b(?:sk|pk|rk)[-_][A-Za-z0-9_-]{12,}/g,
|
|
102
|
+
/\bnpm_[A-Za-z0-9]{20,}/g,
|
|
103
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}/g,
|
|
104
|
+
/\bBearer\s+[A-Za-z0-9._~+/-]{12,}=*/gi,
|
|
105
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
106
|
+
/("(?:api[_-]?key|authorization|token|secret|password)"\s*:\s*)"[^"]*"/gi,
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The provider's own account of what was wrong, safe to log.
|
|
111
|
+
*
|
|
112
|
+
* Prefers the structured `error.message` a JSON body carries, because that is
|
|
113
|
+
* the field vendors put the actionable sentence in and it is bounded; falls
|
|
114
|
+
* back to the raw text. Truncated, and every credential shape replaced.
|
|
115
|
+
*/
|
|
116
|
+
export function vendorDetail(body: unknown): string | undefined {
|
|
117
|
+
if (body === undefined || body === null) return undefined
|
|
118
|
+
|
|
119
|
+
let text: string
|
|
120
|
+
if (typeof body === 'string') {
|
|
121
|
+
try {
|
|
122
|
+
text = pickMessage(JSON.parse(body)) ?? body
|
|
123
|
+
} catch {
|
|
124
|
+
text = body
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
text = pickMessage(body) ?? ''
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const cleaned = redactSecrets(text.trim())
|
|
131
|
+
if (cleaned.length === 0) return undefined
|
|
132
|
+
return cleaned.length > DETAIL_MAX ? `${cleaned.slice(0, DETAIL_MAX)}…` : cleaned
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function redactSecrets(text: string): string {
|
|
136
|
+
let out = text
|
|
137
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
138
|
+
out = out.replace(pattern, (_match, prefix?: string) =>
|
|
139
|
+
prefix === undefined ? '[redacted]' : `${prefix}"[redacted]"`,
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
return out
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function pickMessage(value: unknown): string | undefined {
|
|
146
|
+
if (typeof value === 'string') return value
|
|
147
|
+
if (typeof value !== 'object' || value === null) return undefined
|
|
148
|
+
const node = value as Record<string, unknown>
|
|
149
|
+
// `{ error: { message } }` is what every wire in this tree uses; the rest
|
|
150
|
+
// are the shapes seen in the drivers' own fixtures.
|
|
151
|
+
const nested = node.error
|
|
152
|
+
if (typeof nested === 'string') return nested
|
|
153
|
+
if (typeof nested === 'object' && nested !== null) {
|
|
154
|
+
const message = (nested as Record<string, unknown>).message
|
|
155
|
+
if (typeof message === 'string') return message
|
|
61
156
|
}
|
|
157
|
+
if (typeof node.message === 'string') return node.message
|
|
158
|
+
return undefined
|
|
62
159
|
}
|
|
63
160
|
|
|
64
161
|
/** Is this a classified provider failure, whichever SDK copy threw it? */
|
|
@@ -312,11 +409,13 @@ export function providerVendorError(input: {
|
|
|
312
409
|
input.retryAfter ?? vendorRetryAfter(error),
|
|
313
410
|
input.now ?? Date.now(),
|
|
314
411
|
)
|
|
412
|
+
const detail = vendorDetail(message)
|
|
315
413
|
return new ProviderRequestError({
|
|
316
414
|
kind,
|
|
317
415
|
providerId: input.providerId,
|
|
318
416
|
...(status !== undefined ? { status } : {}),
|
|
319
417
|
...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
|
|
418
|
+
...(detail !== undefined ? { detail } : {}),
|
|
320
419
|
})
|
|
321
420
|
}
|
|
322
421
|
|
|
@@ -335,10 +434,12 @@ export function providerHttpError(input: {
|
|
|
335
434
|
}): ProviderRequestError {
|
|
336
435
|
const kind = classifyProviderHttpStatus(input.status, input.body)
|
|
337
436
|
const retryAfterMs = parseRetryAfterMs(input.retryAfter, input.now ?? Date.now())
|
|
437
|
+
const detail = vendorDetail(input.body)
|
|
338
438
|
return new ProviderRequestError({
|
|
339
439
|
kind,
|
|
340
440
|
providerId: input.providerId,
|
|
341
441
|
status: input.status,
|
|
342
442
|
...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
|
|
443
|
+
...(detail !== undefined ? { detail } : {}),
|
|
343
444
|
})
|
|
344
445
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a versioned model id, given the vocabulary that names one.
|
|
3
|
+
*
|
|
4
|
+
* Several vendors spell an id the same way — a product segment, a family, a
|
|
5
|
+
* major version, sometimes a minor, and sometimes an 8-digit release date:
|
|
6
|
+
*
|
|
7
|
+
* ```
|
|
8
|
+
* <product>-<family>-<major>[-<minor>][-<YYYYMMDD>]
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* The SHAPE is general and lives here; the vocabulary is not, and is supplied
|
|
12
|
+
* by the driver that knows it. That split is deliberate: a driver package
|
|
13
|
+
* exists to speak one service's dialect, and the kernel has no business
|
|
14
|
+
* knowing whose ids these are.
|
|
15
|
+
*
|
|
16
|
+
* It exists at all because three drivers had each written the same matcher and
|
|
17
|
+
* all three had the same defect: the minor-version group was `\d+`, which
|
|
18
|
+
* happily swallowed the date. An id naming no minor therefore parsed as
|
|
19
|
+
* `major.<the date>` and compared as enormously NEWER than one that does, so
|
|
20
|
+
* every capability gate keyed on `minor >= n` inverted for exactly those ids —
|
|
21
|
+
* a model was told it supported features it does not.
|
|
22
|
+
*
|
|
23
|
+
* A real minor version is one to three digits; a date is exactly eight.
|
|
24
|
+
* Bounding the group is what stops it reaching across the separator, and the
|
|
25
|
+
* expression then backtracks into leaving the minor absent so the date suffix
|
|
26
|
+
* can match where it belongs.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export interface ModelVersion {
|
|
30
|
+
readonly family: string
|
|
31
|
+
readonly major: number
|
|
32
|
+
/** `0` when the id names no minor — a bare major is `<major>.0`. */
|
|
33
|
+
readonly minor: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* What a driver must say to have its ids parsed.
|
|
38
|
+
*
|
|
39
|
+
* Every field is a wire value the driver already carries. Passing them in
|
|
40
|
+
* rather than hard-coding them is what keeps this module free of any one
|
|
41
|
+
* service's names.
|
|
42
|
+
*/
|
|
43
|
+
export interface ModelIdGrammar {
|
|
44
|
+
/** The product segment an id begins with. */
|
|
45
|
+
readonly product: string
|
|
46
|
+
/** The family segment that follows it. */
|
|
47
|
+
readonly families: readonly string[]
|
|
48
|
+
/** An optional routing segment a gateway may prepend, including its slash. */
|
|
49
|
+
readonly routingPrefix?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const quoteMeta = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
53
|
+
|
|
54
|
+
function expressionFor(grammar: ModelIdGrammar): RegExp {
|
|
55
|
+
const routing = grammar.routingPrefix ? `(?:${quoteMeta(grammar.routingPrefix)})?` : ''
|
|
56
|
+
// `.map(quoteMeta)` and not `.map(escape)`: the latter resolves to the
|
|
57
|
+
// global, deprecated `escape()`, which percent-encodes instead of escaping
|
|
58
|
+
// regex metacharacters — and would pass every test here, because no family
|
|
59
|
+
// name contains a character either function changes.
|
|
60
|
+
const families = grammar.families.map((f) => quoteMeta(f)).join('|')
|
|
61
|
+
// `\d{1,3}` on the minor, not `\d+`. That single bound is the whole fix.
|
|
62
|
+
return new RegExp(
|
|
63
|
+
`^${routing}${quoteMeta(grammar.product)}-(${families})-(\\d+)(?:[-_.](\\d{1,3}))?(?:-\\d{8})?$`,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** `undefined` for anything the grammar does not describe — never a guess. */
|
|
68
|
+
export function parseVersionedModelId(
|
|
69
|
+
id: string,
|
|
70
|
+
grammar: ModelIdGrammar,
|
|
71
|
+
): ModelVersion | undefined {
|
|
72
|
+
const match = id.toLowerCase().match(expressionFor(grammar))
|
|
73
|
+
if (!match) return undefined
|
|
74
|
+
return {
|
|
75
|
+
family: match[1] as string,
|
|
76
|
+
major: Number(match[2]),
|
|
77
|
+
minor: match[3] === undefined ? 0 : Number(match[3]),
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Whether an id names a version at or above `major.minor`.
|
|
83
|
+
*
|
|
84
|
+
* The comparison every caller was writing by hand. An id this cannot parse
|
|
85
|
+
* returns `false`: a capability gate must not open for a name it does not
|
|
86
|
+
* understand, which is the fail-safe reading and the one those callers' own
|
|
87
|
+
* comments already claimed.
|
|
88
|
+
*/
|
|
89
|
+
export function modelVersionAtLeast(
|
|
90
|
+
id: string,
|
|
91
|
+
grammar: ModelIdGrammar,
|
|
92
|
+
major: number,
|
|
93
|
+
minor: number,
|
|
94
|
+
): boolean {
|
|
95
|
+
const version = parseVersionedModelId(id, grammar)
|
|
96
|
+
if (!version) return false
|
|
97
|
+
return version.major > major || (version.major === major && version.minor >= minor)
|
|
98
|
+
}
|