@namzu/sdk 5.2.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/dist/provider/__tests__/strict-schema.test.js +50 -2
  3. package/dist/provider/__tests__/strict-schema.test.js.map +1 -1
  4. package/dist/provider/__tests__/vendor-detail.test.d.ts +2 -0
  5. package/dist/provider/__tests__/vendor-detail.test.d.ts.map +1 -0
  6. package/dist/provider/__tests__/vendor-detail.test.js +89 -0
  7. package/dist/provider/__tests__/vendor-detail.test.js.map +1 -0
  8. package/dist/provider/errors.d.ts +38 -5
  9. package/dist/provider/errors.d.ts.map +1 -1
  10. package/dist/provider/errors.js +107 -5
  11. package/dist/provider/errors.js.map +1 -1
  12. package/dist/provider/strict-schema.d.ts.map +1 -1
  13. package/dist/provider/strict-schema.js +64 -8
  14. package/dist/provider/strict-schema.js.map +1 -1
  15. package/dist/public-runtime.d.ts +3 -0
  16. package/dist/public-runtime.d.ts.map +1 -1
  17. package/dist/public-runtime.js +6 -0
  18. package/dist/public-runtime.js.map +1 -1
  19. package/dist/registry/tool/__tests__/dialect.test.d.ts +2 -0
  20. package/dist/registry/tool/__tests__/dialect.test.d.ts.map +1 -0
  21. package/dist/registry/tool/__tests__/dialect.test.js +143 -0
  22. package/dist/registry/tool/__tests__/dialect.test.js.map +1 -0
  23. package/dist/registry/tool/dialect.d.ts +50 -0
  24. package/dist/registry/tool/dialect.d.ts.map +1 -0
  25. package/dist/registry/tool/dialect.js +131 -0
  26. package/dist/registry/tool/dialect.js.map +1 -0
  27. package/dist/registry/toolset/catalog.d.ts.map +1 -1
  28. package/dist/registry/toolset/catalog.js +10 -5
  29. package/dist/registry/toolset/catalog.js.map +1 -1
  30. package/dist/runtime/query/__tests__/stream-recovery.test.js +6 -0
  31. package/dist/runtime/query/__tests__/stream-recovery.test.js.map +1 -1
  32. package/dist/runtime/query/result.d.ts.map +1 -1
  33. package/dist/runtime/query/result.js +6 -0
  34. package/dist/runtime/query/result.js.map +1 -1
  35. package/dist/types/provider/error.d.ts +20 -4
  36. package/dist/types/provider/error.d.ts.map +1 -1
  37. package/package.json +1 -1
  38. package/src/provider/__tests__/strict-schema.test.ts +58 -2
  39. package/src/provider/__tests__/vendor-detail.test.ts +107 -0
  40. package/src/provider/errors.ts +106 -5
  41. package/src/provider/strict-schema.ts +65 -8
  42. package/src/public-runtime.ts +7 -0
  43. package/src/registry/tool/__tests__/dialect.test.ts +197 -0
  44. package/src/registry/tool/dialect.ts +136 -0
  45. package/src/registry/toolset/catalog.ts +10 -5
  46. package/src/runtime/query/__tests__/stream-recovery.test.ts +6 -0
  47. package/src/runtime/query/result.ts +6 -0
  48. package/src/types/provider/error.ts +20 -4
@@ -78,6 +78,13 @@ export { modelVersionAtLeast, parseVersionedModelId } from './provider/model-ver
78
78
  // Strict tool input is a SUBSET of JSON Schema, and a keyword outside it makes
79
79
  // the vendor reject the whole request rather than degrade one field.
80
80
  export { assertStrictSchema, findStrictSchemaViolations } from './provider/strict-schema.js'
81
+ // A tool has one schema; what changes per provider is the DIALECT the wire
82
+ // parses, which is the wire's property. Rendered once, converted at the driver.
83
+ export { findDraft07Only, toSchemaDialect } from './registry/tool/dialect.js'
84
+ export type { JsonSchemaDialect } from './registry/tool/dialect.js'
85
+ // The renderer itself, so a driver or a contract test can ask what a tool will
86
+ // actually put on the wire without reaching into the registry.
87
+ export { renderToolSchema } from './registry/tool/schema.js'
81
88
  export type { StrictSchemaViolation } from './provider/strict-schema.js'
82
89
  export type { ModelIdGrammar, ModelVersion } from './provider/model-version.js'
83
90
  export { drainQuery, query } from './runtime/query/index.js'
@@ -0,0 +1,197 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { z } from 'zod'
3
+
4
+ import { findDraft07Only, toSchemaDialect } from '../dialect.js'
5
+ import { renderToolSchema } from '../schema.js'
6
+
7
+ /**
8
+ * The mechanism, tested in the kernel that owns it.
9
+ *
10
+ * The drivers each have their own test proving the conversion reaches their
11
+ * wire. This one is about the conversion itself: what it rewrites, what it
12
+ * deliberately leaves alone, and the two properties the prompt cache depends
13
+ * on — a stable reference and a frozen result.
14
+ */
15
+
16
+ describe('saying a schema in the dialect a wire parses', () => {
17
+ it('moves a tuple from `items` to `prefixItems`', () => {
18
+ const draft07 = {
19
+ type: 'object',
20
+ properties: {
21
+ range: {
22
+ type: 'array',
23
+ items: [{ type: 'integer' }, { type: 'integer' }],
24
+ minItems: 2,
25
+ maxItems: 2,
26
+ },
27
+ },
28
+ }
29
+
30
+ expect(toSchemaDialect(draft07, '2020-12')).toEqual({
31
+ type: 'object',
32
+ properties: {
33
+ range: {
34
+ type: 'array',
35
+ prefixItems: [{ type: 'integer' }, { type: 'integer' }],
36
+ minItems: 2,
37
+ maxItems: 2,
38
+ },
39
+ },
40
+ })
41
+ })
42
+
43
+ it('leaves a homogeneous array alone, where `items` means the same thing', () => {
44
+ // The distinction the whole conversion turns on: `items` is only a
45
+ // tuple when it holds an ARRAY of schemas. One schema means "every
46
+ // element", which both dialects spell identically.
47
+ const schema = { type: 'array', items: { type: 'string' } }
48
+
49
+ expect(toSchemaDialect(schema, '2020-12')).toEqual(schema)
50
+ })
51
+
52
+ it('turns `additionalItems` into the 2020-12 `items`', () => {
53
+ // `additionalItems` only ever qualified an array-form `items` — it says
54
+ // what the elements AFTER the tuple look like. 2020-12 gave that job to
55
+ // `items` once `prefixItems` holds the positional schemas.
56
+ const converted = toSchemaDialect(
57
+ {
58
+ type: 'array',
59
+ items: [{ type: 'integer' }],
60
+ additionalItems: { type: 'string' },
61
+ },
62
+ '2020-12',
63
+ )
64
+
65
+ expect(converted).toEqual({
66
+ type: 'array',
67
+ prefixItems: [{ type: 'integer' }],
68
+ items: { type: 'string' },
69
+ })
70
+ })
71
+
72
+ it('drops `additionalItems: false`, which both dialects already imply', () => {
73
+ // Not a lossy shortcut: once `prefixItems` is set, a closed tuple is the
74
+ // default in 2020-12, so emitting `items: false` would add a byte to
75
+ // every request to say what was already true.
76
+ expect(
77
+ toSchemaDialect(
78
+ { type: 'array', items: [{ type: 'integer' }], additionalItems: false },
79
+ '2020-12',
80
+ ),
81
+ ).toEqual({ type: 'array', prefixItems: [{ type: 'integer' }] })
82
+ })
83
+
84
+ it('ignores `additionalItems` with no tuple to qualify', () => {
85
+ // Meaningless in draft-07 too, so carrying it forward would be inventing
86
+ // a constraint the author did not write.
87
+ expect(
88
+ toSchemaDialect(
89
+ { type: 'array', items: { type: 'string' }, additionalItems: { type: 'integer' } },
90
+ '2020-12',
91
+ ),
92
+ ).toEqual({ type: 'array', items: { type: 'string' } })
93
+ })
94
+
95
+ it('converts a tuple nested inside another tuple', () => {
96
+ const converted = toSchemaDialect(
97
+ { type: 'array', items: [{ type: 'array', items: [{ type: 'integer' }] }] },
98
+ '2020-12',
99
+ ) as Record<string, Record<string, unknown>[]>
100
+
101
+ expect(converted.prefixItems?.[0]).toEqual({
102
+ type: 'array',
103
+ prefixItems: [{ type: 'integer' }],
104
+ })
105
+ })
106
+
107
+ it('hands back the very same object for draft-07', () => {
108
+ // Not an equal object — the SAME one. The tools block sits at position 0
109
+ // of the prompt-cache prefix, so a driver that speaks draft-07 must not
110
+ // pay an allocation or risk a differently-ordered copy per request.
111
+ const schema = { type: 'object' }
112
+
113
+ expect(toSchemaDialect(schema, 'draft-07')).toBe(schema)
114
+ })
115
+
116
+ it('returns the same converted object every time it is asked', () => {
117
+ // Same reason. Conversion runs once per schema per dialect; a fresh
118
+ // object each iteration would invalidate the cache for the whole run
119
+ // even though the bytes were equal.
120
+ const schema = { type: 'array', items: [{ type: 'integer' }] }
121
+
122
+ expect(toSchemaDialect(schema, '2020-12')).toBe(toSchemaDialect(schema, '2020-12'))
123
+ })
124
+
125
+ it('freezes what it hands out, all the way down', () => {
126
+ // A caller that mutates a cached schema would poison every later render,
127
+ // and the symptom would be a silently invalidated prompt cache rather
128
+ // than an error. Freezing turns that into a throw at the mutation site.
129
+ const converted = toSchemaDialect(
130
+ { type: 'object', properties: { a: { type: 'array', items: [{ type: 'integer' }] } } },
131
+ '2020-12',
132
+ ) as { properties: { a: { prefixItems: unknown[] } } }
133
+
134
+ expect(Object.isFrozen(converted)).toBe(true)
135
+ expect(Object.isFrozen(converted.properties.a)).toBe(true)
136
+ expect(Object.isFrozen(converted.properties.a.prefixItems)).toBe(true)
137
+ })
138
+ })
139
+
140
+ describe('finding what a 2020-12 wire will refuse', () => {
141
+ it('names the path to an array-form `items`', () => {
142
+ expect(
143
+ findDraft07Only({
144
+ type: 'object',
145
+ properties: { range: { type: 'array', items: [{ type: 'integer' }] } },
146
+ }),
147
+ ).toEqual(['properties.range.items'])
148
+ })
149
+
150
+ it('names `additionalItems` too', () => {
151
+ expect(findDraft07Only({ additionalItems: false })).toEqual(['additionalItems'])
152
+ })
153
+
154
+ it('walks into arrays, indexing the branch', () => {
155
+ expect(
156
+ findDraft07Only({
157
+ anyOf: [{ type: 'string' }, { type: 'array', items: [{ type: 'integer' }] }],
158
+ }),
159
+ ).toEqual(['anyOf[1].items'])
160
+ })
161
+
162
+ it('does not descend into a tuple it has already reported', () => {
163
+ // Reporting the tuple and then each of its positional schemas would
164
+ // turn one fixable finding into a list nobody reads.
165
+ expect(
166
+ findDraft07Only({ type: 'array', items: [{ type: 'integer' }, { type: 'string' }] }),
167
+ ).toEqual(['items'])
168
+ })
169
+
170
+ it('says nothing about a schema that is already 2020-12', () => {
171
+ expect(
172
+ findDraft07Only({
173
+ type: 'array',
174
+ prefixItems: [{ type: 'integer' }],
175
+ items: { type: 'string' },
176
+ }),
177
+ ).toEqual([])
178
+ })
179
+
180
+ it('tolerates the leaves', () => {
181
+ expect(findDraft07Only(null)).toEqual([])
182
+ expect(findDraft07Only('a string')).toEqual([])
183
+ expect(findDraft07Only(42)).toEqual([])
184
+ })
185
+ })
186
+
187
+ describe('the round trip a real tool takes', () => {
188
+ it('renders a Zod tuple as draft-07 and converts it clean', () => {
189
+ // The actual defect, end to end: this is what `read.readRange` is.
190
+ const rendered = renderToolSchema(
191
+ z.object({ readRange: z.tuple([z.number(), z.number()]).optional() }),
192
+ )
193
+
194
+ expect(findDraft07Only(rendered)).not.toEqual([])
195
+ expect(findDraft07Only(toSchemaDialect(rendered, '2020-12'))).toEqual([])
196
+ })
197
+ })
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The dialect a wire speaks, and how to say the same schema in it.
3
+ *
4
+ * A tool has one Zod schema. What changes between providers is not the tool —
5
+ * it is the JSON Schema dialect the wire parses, which is a property of the
6
+ * wire. So the shape is rendered once, canonically, and each driver converts
7
+ * at the boundary where it knows which wire it is about to talk to.
8
+ *
9
+ * This exists because that layering was missing and it cost a production
10
+ * outage. `renderToolSchema` emits draft-07 (zod-to-json-schema's
11
+ * `jsonSchema7` target), every driver forwarded it verbatim, and one of the
12
+ * wires namzu speaks requires draft 2020-12. Measured against that live
13
+ * endpoint:
14
+ *
15
+ * | tool schema | result |
16
+ * |--------------------------------|-----------------------------------------|
17
+ * | `items: [a, b]` (draft-07) | 400 — "must match JSON Schema draft 2020-12" |
18
+ * | `prefixItems: [a, b]` (2020-12)| accepted |
19
+ * | `items: { a }` | accepted |
20
+ *
21
+ * The failure is NOT about strict tool use. It fires with strict validation
22
+ * unset, and with it on the dialect error arrives *before* the strict-subset
23
+ * error — so a guard scoped to strict misses it entirely. One was, and did.
24
+ *
25
+ * Which wires want which dialect is the drivers' knowledge, not this file's:
26
+ * the vocabulary lives beside the wire that speaks it, and only the mechanism
27
+ * lives here.
28
+ *
29
+ * Only the conversions namzu can actually emit are implemented. The renderer
30
+ * runs with `$refStrategy: 'none'`, so there are no `$ref`/`definitions` to
31
+ * rewrite; a construct that cannot appear is not worth code that cannot be
32
+ * tested.
33
+ */
34
+
35
+ /** Which spelling of JSON Schema a wire accepts. */
36
+ export type JsonSchemaDialect = 'draft-07' | '2020-12'
37
+
38
+ /**
39
+ * Rendered schemas are memoized and deeply frozen, so their identity is
40
+ * stable for the life of the tool — which makes them a sound `WeakMap` key.
41
+ *
42
+ * Converting per request would re-walk every tool's tree on every iteration,
43
+ * which is the waste `renderToolSchema`'s own cache exists to remove, and it
44
+ * would hand a fresh object to the wire each time. The tools block renders at
45
+ * position 0 of the prompt-cache prefix, so a differently-ordered but equal
46
+ * object still invalidates the cache for the whole run. Caching the conversion
47
+ * keeps the bytes identical across iterations.
48
+ */
49
+ const CONVERTED = new Map<JsonSchemaDialect, WeakMap<object, Record<string, unknown>>>()
50
+
51
+ /**
52
+ * Say this schema in the dialect the wire parses.
53
+ *
54
+ * Returns the input unchanged — same reference — when nothing needs saying
55
+ * differently, so the common case costs one map lookup and no allocation.
56
+ */
57
+ export function toSchemaDialect(
58
+ schema: Record<string, unknown>,
59
+ dialect: JsonSchemaDialect,
60
+ ): Record<string, unknown> {
61
+ if (dialect === 'draft-07') return schema
62
+
63
+ let cache = CONVERTED.get(dialect)
64
+ if (!cache) {
65
+ cache = new WeakMap()
66
+ CONVERTED.set(dialect, cache)
67
+ }
68
+ const hit = cache.get(schema)
69
+ if (hit) return hit
70
+
71
+ const converted = to2020(schema) as Record<string, unknown>
72
+ const frozen = deepFreeze(converted)
73
+ cache.set(schema, frozen)
74
+ return frozen
75
+ }
76
+
77
+ /**
78
+ * Whether a schema still carries a construct the 2020-12 wire refuses.
79
+ *
80
+ * Exported so a test can sweep every shipped tool without reaching into the
81
+ * conversion, and so a driver can assert rather than hope.
82
+ */
83
+ export function findDraft07Only(schema: unknown, path = ''): string[] {
84
+ if (Array.isArray(schema)) {
85
+ return schema.flatMap((item, i) => findDraft07Only(item, `${path}[${i}]`))
86
+ }
87
+ if (typeof schema !== 'object' || schema === null) return []
88
+
89
+ const node = schema as Record<string, unknown>
90
+ const found: string[] = []
91
+ // A tuple. In draft-07 the positional schemas live in `items`; 2020-12
92
+ // moved them to `prefixItems` and kept `items` for the rest, so the array
93
+ // form is not merely old — it means something else now, and the wire
94
+ // rejects the whole request rather than one field.
95
+ if (Array.isArray(node.items)) found.push(`${path ? `${path}.` : ''}items`)
96
+ // `additionalItems` only ever qualified an array-form `items`; 2020-12
97
+ // spells that `items`.
98
+ if ('additionalItems' in node) found.push(`${path ? `${path}.` : ''}additionalItems`)
99
+
100
+ for (const [key, value] of Object.entries(node)) {
101
+ if (key === 'items' && Array.isArray(value)) continue
102
+ found.push(...findDraft07Only(value, path ? `${path}.${key}` : key))
103
+ }
104
+ return found
105
+ }
106
+
107
+ function to2020(value: unknown): unknown {
108
+ if (Array.isArray(value)) return value.map(to2020)
109
+ if (typeof value !== 'object' || value === null) return value
110
+
111
+ const node = value as Record<string, unknown>
112
+ const out: Record<string, unknown> = {}
113
+ for (const [key, child] of Object.entries(node)) {
114
+ if (key === 'items' && Array.isArray(child)) {
115
+ out.prefixItems = child.map(to2020)
116
+ continue
117
+ }
118
+ if (key === 'additionalItems') {
119
+ // Only meaningful alongside an array-form `items`, where 2020-12
120
+ // calls the same thing `items`. `false` is the default in both
121
+ // dialects once `prefixItems` is set, so it carries nothing.
122
+ if (Array.isArray(node.items) && child !== false) out.items = to2020(child)
123
+ continue
124
+ }
125
+ out[key] = to2020(child)
126
+ }
127
+ return out
128
+ }
129
+
130
+ function deepFreeze<T>(value: T): T {
131
+ if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return value
132
+ for (const key of Object.keys(value as Record<string, unknown>)) {
133
+ deepFreeze((value as Record<string, unknown>)[key])
134
+ }
135
+ return Object.freeze(value)
136
+ }
@@ -1,4 +1,3 @@
1
- import { zodToJsonSchema } from 'zod-to-json-schema'
2
1
  import type { ToolRegistryContract } from '../../types/tool/index.js'
3
2
  import type { LLMToolSchema, ToolAvailability, ToolDefinition } from '../../types/tool/index.js'
4
3
  import type {
@@ -10,6 +9,7 @@ import type {
10
9
  ToolsetDefinition,
11
10
  ToolsetPolicy,
12
11
  } from '../../types/toolset/index.js'
12
+ import { renderToolSchema } from '../tool/schema.js'
13
13
 
14
14
  export interface ToolCatalogSearchOptions {
15
15
  readonly loading?: readonly ToolLoadingMode[]
@@ -245,12 +245,17 @@ function toolDefinitionToLLMTool(definition: ToolDefinition | undefined): LLMToo
245
245
  function: {
246
246
  name: definition.name,
247
247
  description: definition.description,
248
+ // Through `renderToolSchema`, not a second inline conversion. The
249
+ // options were already identical, so this was not a different
250
+ // rendering — it was the same rendering without the guarantees:
251
+ // no `$schema` stripping (that key rides in the tools block, which
252
+ // sits at position 0 inside the prompt-cache prefix), no memoization,
253
+ // and no freeze. Two paths that agree today are two paths that can
254
+ // disagree tomorrow, and a tool reaching the wire through the catalog
255
+ // rather than the registry is not a different tool.
248
256
  parameters:
249
257
  (definition.modelInputSchema ? structuredClone(definition.modelInputSchema) : undefined) ??
250
- (zodToJsonSchema(definition.inputSchema, {
251
- target: 'jsonSchema7',
252
- $refStrategy: 'none',
253
- }) as Record<string, unknown>),
258
+ renderToolSchema(definition.inputSchema),
254
259
  },
255
260
  }
256
261
  }
@@ -84,6 +84,7 @@ class ClassifiedFailureProvider implements LLMProvider {
84
84
  providerId: 'classified-failure',
85
85
  status: 429,
86
86
  retryAfterMs: 2000,
87
+ detail: 'rate limit reached for this organization',
87
88
  }),
88
89
  )
89
90
  }
@@ -210,11 +211,16 @@ describe('query stream recovery', () => {
210
211
  )
211
212
 
212
213
  expect(run.status).toBe('failed')
214
+ // `detail` rides along with the classification. Without it a host
215
+ // rendering this metadata knows a request was rejected but not why, and
216
+ // has to go re-parse the message string — which is the re-parsing this
217
+ // structured field exists to avoid.
213
218
  expect(run.lastProviderError).toEqual({
214
219
  kind: 'throttle',
215
220
  providerId: 'classified-failure',
216
221
  status: 429,
217
222
  retryAfterMs: 2000,
223
+ detail: 'rate limit reached for this organization',
218
224
  })
219
225
  expect(events.find((event) => event.type === 'run_failed')).toMatchObject({
220
226
  type: 'run_failed',
@@ -129,6 +129,12 @@ export class ResultAssembler {
129
129
  providerId: err.providerId,
130
130
  ...(err.status !== undefined ? { status: err.status } : {}),
131
131
  ...(err.retryAfterMs !== undefined ? { retryAfterMs: err.retryAfterMs } : {}),
132
+ // The provider's own sentence, already truncated and scrubbed
133
+ // by the driver. Without it a host rendering this metadata
134
+ // knows a request was rejected but not which field, and has to
135
+ // go re-parse `error` to find out — which is exactly the
136
+ // re-parsing the line above says this exists to avoid.
137
+ ...(err.detail !== undefined ? { detail: err.detail } : {}),
132
138
  }
133
139
  : undefined
134
140
  runMgr.markFailed(errorMessage, providerError)
@@ -10,20 +10,36 @@ export type ProviderErrorKind =
10
10
  /**
11
11
  * Serializable provider-failure metadata carried by failed runs and events.
12
12
  *
13
- * No response body, vendor message, URL, or `cause` belongs here.
13
+ * No response body, URL, or `cause` belongs here. `detail` is the one thing
14
+ * the provider itself said, and it arrives scrubbed — see below.
14
15
  */
15
16
  export interface ProviderErrorInfo {
16
17
  readonly kind: ProviderErrorKind
17
18
  readonly providerId: string
18
19
  readonly status?: number
19
20
  readonly retryAfterMs?: number
21
+ /**
22
+ * What the provider said was wrong, truncated and scrubbed of anything
23
+ * credential-shaped.
24
+ *
25
+ * Carried here and not only on the error's `message` for the same reason
26
+ * `kind` is: a host rendering a failure should not have to parse a
27
+ * sentence to show one. It is the field that names the offending
28
+ * parameter, which is usually the whole diagnosis.
29
+ */
30
+ readonly detail?: string
20
31
  }
21
32
 
22
33
  export interface ProviderRequestErrorInit extends ProviderErrorInfo {
23
34
  /**
24
- * Optional extra clause for the message. MUST be text this codebase
25
- * authored — never a fragment of a response body, a header, or a vendor
26
- * error.
35
+ * Optional extra clause for the message.
36
+ *
37
+ * This used to be required to be text the codebase authored, never a
38
+ * fragment of a vendor error — and the constructor did not read the field
39
+ * at all, so nothing carried either kind of text. Providers may now pass
40
+ * their own complaint through `vendorDetail`, which truncates it and
41
+ * replaces anything credential-shaped. Text this codebase authored is
42
+ * still welcome; what is not welcome is a raw body passed straight in.
27
43
  */
28
44
  readonly detail?: string
29
45
  }