@stravigor/saina 0.4.7 → 0.4.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravigor/saina",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
4
4
  "type": "module",
5
5
  "description": "AI module for the Strav framework",
6
6
  "license": "MIT",
package/src/helpers.ts CHANGED
@@ -216,6 +216,7 @@ export class AgentRunner<T extends Agent = Agent> {
216
216
  private _context: Record<string, unknown> = {}
217
217
  private _provider?: string
218
218
  private _model?: string
219
+ private _tools?: ToolDefinition[]
219
220
 
220
221
  constructor(private AgentClass: new () => T) {}
221
222
 
@@ -238,11 +239,22 @@ export class AgentRunner<T extends Agent = Agent> {
238
239
  return this
239
240
  }
240
241
 
242
+ /** Set or override the tools available to the agent for this run. */
243
+ tools(tools: ToolDefinition[]): this {
244
+ this._tools = tools
245
+ return this
246
+ }
247
+
241
248
  /** Run the agent to completion. */
242
249
  async run(): Promise<AgentResult> {
243
250
  const agent = new this.AgentClass()
244
251
  const config = SainaManager.config
245
252
 
253
+ // Runner-level tools override agent-level tools
254
+ if (this._tools) {
255
+ agent.tools = this._tools
256
+ }
257
+
246
258
  const providerName = this._provider ?? agent.provider ?? config.default
247
259
  const providerConfig = config.providers[providerName]
248
260
  const model = this._model ?? agent.model ?? providerConfig?.model ?? ''
@@ -369,6 +381,11 @@ export class AgentRunner<T extends Agent = Agent> {
369
381
  const agent = new this.AgentClass()
370
382
  const config = SainaManager.config
371
383
 
384
+ // Runner-level tools override agent-level tools
385
+ if (this._tools) {
386
+ agent.tools = this._tools
387
+ }
388
+
372
389
  const providerName = this._provider ?? agent.provider ?? config.default
373
390
  const providerConfig = config.providers[providerName]
374
391
  const model = this._model ?? agent.model ?? providerConfig?.model ?? ''
@@ -179,6 +179,14 @@ export class OpenAIProvider implements AIProvider {
179
179
 
180
180
  // ── Private helpers ──────────────────────────────────────────────────────
181
181
 
182
+ private isReasoningModel(model: string): boolean {
183
+ return /^(o[1-9]|gpt-5)/.test(model)
184
+ }
185
+
186
+ private usesMaxCompletionTokens(model: string): boolean {
187
+ return this.isReasoningModel(model) || /^gpt-4\.1|gpt-4o-mini-2024/.test(model)
188
+ }
189
+
182
190
  private buildHeaders(): Record<string, string> {
183
191
  return {
184
192
  'content-type': 'application/json',
@@ -194,9 +202,18 @@ export class OpenAIProvider implements AIProvider {
194
202
 
195
203
  if (stream) body.stream = true
196
204
  if (request.maxTokens ?? this.defaultMaxTokens) {
197
- body.max_tokens = request.maxTokens ?? this.defaultMaxTokens
205
+ const tokens = request.maxTokens ?? this.defaultMaxTokens
206
+ const model = (body.model as string) ?? ''
207
+
208
+ if (this.usesMaxCompletionTokens(model)) {
209
+ body.max_completion_tokens = tokens
210
+ } else {
211
+ body.max_tokens = tokens
212
+ }
213
+ }
214
+ if (request.temperature !== undefined && !this.isReasoningModel((body.model as string) ?? '')) {
215
+ body.temperature = request.temperature
198
216
  }
199
- if (request.temperature !== undefined) body.temperature = request.temperature
200
217
  if (request.stopSequences?.length) body.stop = request.stopSequences
201
218
 
202
219
  // Tools
@@ -225,19 +242,20 @@ export class OpenAIProvider implements AIProvider {
225
242
 
226
243
  // Structured output
227
244
  if (request.schema) {
228
- if (this.supportsJsonSchema) {
245
+ const useStrict = this.supportsJsonSchema && this.isStrictCompatible(request.schema)
246
+
247
+ if (useStrict) {
229
248
  body.response_format = {
230
249
  type: 'json_schema',
231
250
  json_schema: {
232
251
  name: 'response',
233
- schema: request.schema,
252
+ schema: this.normalizeSchemaForOpenAI(request.schema),
234
253
  strict: true,
235
254
  },
236
255
  }
237
256
  } else {
238
- // Fallback for providers that don't support json_schema (e.g. DeepSeek)
257
+ // Fallback: json_object mode with schema injected into system prompt
239
258
  body.response_format = { type: 'json_object' }
240
- // Inject schema into system prompt so the model knows the expected format
241
259
  const schemaHint = `\n\nYou MUST respond with valid JSON matching this schema:\n${JSON.stringify(request.schema, null, 2)}`
242
260
  const messages = body.messages as any[]
243
261
  if (messages[0]?.role === 'system') {
@@ -348,4 +366,150 @@ export class OpenAIProvider implements AIProvider {
348
366
  raw: data,
349
367
  }
350
368
  }
369
+
370
+ /**
371
+ * OpenAI's strict structured output requires:
372
+ * - All properties listed in `required`
373
+ * - Optional properties use nullable types instead
374
+ * - `additionalProperties: false` on every object
375
+ */
376
+ /**
377
+ * Check if a schema is compatible with OpenAI's strict structured output.
378
+ * Record types (object with additionalProperties != false) are not supported.
379
+ */
380
+ private isStrictCompatible(schema: Record<string, unknown>): boolean {
381
+ if (schema == null || typeof schema !== 'object') return true
382
+
383
+ // Record type: object with additionalProperties that isn't false
384
+ if (
385
+ schema.type === 'object' &&
386
+ schema.additionalProperties !== undefined &&
387
+ schema.additionalProperties !== false
388
+ ) {
389
+ return false
390
+ }
391
+
392
+ // Check nested properties
393
+ if (schema.properties) {
394
+ for (const prop of Object.values(schema.properties as Record<string, any>)) {
395
+ if (!this.isStrictCompatible(prop)) return false
396
+ }
397
+ }
398
+
399
+ // Check array items
400
+ if (schema.items && !this.isStrictCompatible(schema.items as Record<string, unknown>))
401
+ return false
402
+
403
+ // Check anyOf / oneOf
404
+ for (const key of ['anyOf', 'oneOf'] as const) {
405
+ if (Array.isArray(schema[key])) {
406
+ for (const s of schema[key] as any[]) {
407
+ if (!this.isStrictCompatible(s)) return false
408
+ }
409
+ }
410
+ }
411
+
412
+ return true
413
+ }
414
+
415
+ /** Keywords OpenAI strict mode does NOT support. */
416
+ private static UNSUPPORTED_KEYWORDS = new Set([
417
+ 'propertyNames',
418
+ 'patternProperties',
419
+ 'if',
420
+ 'then',
421
+ 'else',
422
+ 'not',
423
+ 'contains',
424
+ 'minItems',
425
+ 'maxItems',
426
+ 'minProperties',
427
+ 'maxProperties',
428
+ 'minLength',
429
+ 'maxLength',
430
+ 'minimum',
431
+ 'maximum',
432
+ 'exclusiveMinimum',
433
+ 'exclusiveMaximum',
434
+ 'multipleOf',
435
+ 'pattern',
436
+ 'format',
437
+ 'contentEncoding',
438
+ 'contentMediaType',
439
+ 'unevaluatedProperties',
440
+ '$schema',
441
+ ])
442
+
443
+ private normalizeSchemaForOpenAI(schema: Record<string, unknown>): Record<string, unknown> {
444
+ if (schema == null || typeof schema !== 'object') return schema
445
+
446
+ // Strip unsupported keywords
447
+ const result: Record<string, unknown> = {}
448
+ for (const [k, v] of Object.entries(schema)) {
449
+ if (!OpenAIProvider.UNSUPPORTED_KEYWORDS.has(k)) {
450
+ result[k] = v
451
+ }
452
+ }
453
+
454
+ // Handle object types with explicit properties
455
+ if (result.type === 'object' && result.properties) {
456
+ const props = result.properties as Record<string, any>
457
+ const currentRequired = new Set(
458
+ Array.isArray(result.required) ? (result.required as string[]) : []
459
+ )
460
+
461
+ const normalizedProps: Record<string, any> = {}
462
+
463
+ for (const [key, prop] of Object.entries(props)) {
464
+ let normalizedProp = this.normalizeSchemaForOpenAI(prop)
465
+
466
+ // If property is not required, make it nullable and add to required
467
+ if (!currentRequired.has(key)) {
468
+ normalizedProp = this.makeNullable(normalizedProp)
469
+ }
470
+
471
+ normalizedProps[key] = normalizedProp
472
+ }
473
+
474
+ result.properties = normalizedProps
475
+ result.required = Object.keys(normalizedProps)
476
+ result.additionalProperties = false
477
+ }
478
+
479
+ // Handle arrays
480
+ if (result.type === 'array' && result.items) {
481
+ result.items = this.normalizeSchemaForOpenAI(result.items as Record<string, unknown>)
482
+ }
483
+
484
+ // Handle anyOf / oneOf
485
+ for (const key of ['anyOf', 'oneOf'] as const) {
486
+ if (Array.isArray(result[key])) {
487
+ result[key] = (result[key] as any[]).map((s: any) => this.normalizeSchemaForOpenAI(s))
488
+ }
489
+ }
490
+
491
+ return result
492
+ }
493
+
494
+ private makeNullable(schema: Record<string, unknown>): Record<string, unknown> {
495
+ // Already nullable
496
+ if (Array.isArray(schema.type) && schema.type.includes('null')) return schema
497
+
498
+ // Has anyOf — add null variant
499
+ if (Array.isArray(schema.anyOf)) {
500
+ const hasNull = schema.anyOf.some((s: any) => s.type === 'null')
501
+ if (!hasNull) {
502
+ return { ...schema, anyOf: [...schema.anyOf, { type: 'null' }] }
503
+ }
504
+ return schema
505
+ }
506
+
507
+ // Simple type — wrap in anyOf with null
508
+ if (schema.type) {
509
+ const { type, ...rest } = schema
510
+ return { anyOf: [{ type, ...rest }, { type: 'null' }] }
511
+ }
512
+
513
+ return schema
514
+ }
351
515
  }