@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 +1 -1
- package/src/helpers.ts +17 -0
- package/src/providers/openai_provider.ts +170 -6
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|