openvibers/openvibe.ai Commits

AI: a refused json_schema steps down with the schema in the instructions; trade template names its fields (#91)

OP OpenVibers committed 29 days ago · d726efbe66 · 5 files, +28 −6

Changes

ddiff --git a/server/providers/common.js b/server/providers/common.js
iindex 3c56d59..8a44d97 100644
---- a/server/providers/common.js
++++ b/server/providers/common.js
@@@ -72,4 +72,17 @@ function sleep(ms, signal) {
});
}
--module.exports = { ProviderError, deadline, describeAbort, postJson, retryable, approxTokens, estimateUsage, sleep };
++/**
++ * The output schema as an instruction, for a request that cannot carry it as structured output (a gateway or a schema
++ * that refuses json_schema steps down to json_object or prose, and the model would otherwise guess the shape).
++ */
++function schemaInstruction(json) {
++ if (!json || !json.schema) return '';
++ let text;
++ try { text = JSON.stringify(json.schema); } catch { return ''; }
++ if (text.length > 12000) text = text.slice(0, 12000);
++ return `Answer with one JSON object that matches this JSON Schema exactly: every required property present, no other properties, and the types and limits it gives.\n${text}`;
++}
++
++module.exports = {
++ schemaInstruction, ProviderError, deadline, describeAbort, postJson, retryable, approxTokens, estimateUsage, sleep };
ddiff --git a/server/providers/openai.js b/server/providers/openai.js
iindex 80f3002..ed190ca 100644
---- a/server/providers/openai.js
++++ b/server/providers/openai.js
@@@ -14,7 +14,7 @@
*/
const fs = require('fs');
const path = require('path');
--const { ProviderError, postJson, deadline, describeAbort, approxTokens } = require('./common');
++const { ProviderError, postJson, deadline, describeAbort, approxTokens, schemaInstruction } = require('./common');
const { sseEvents, createAccumulator } = require('./stream');
const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
@@@ -24,7 +24,9 @@ function isReasoningModel(m) { return /^(gpt-5|o\d)/i.test(m || ''); }
function buildBody({ model, system, messages, image, json, maxTokens, temperature, cacheKey, baseUrl }, jsonMode) {
const msgs = [];
-- const sysText = (system || []).map(x => x.text).join('\n\n');
++ // Stepped down from json_schema: the schema travels in the instructions instead.
++ const hint = json && jsonMode !== 'schema' ? schemaInstruction(json) : '';
++ const sysText = [...(system || []).map(x => x.text), hint].filter(Boolean).join('\n\n');
if (sysText) msgs.push({ role: 'system', content: sysText });
messages.forEach((m, i) => {
const last = i === messages.length - 1;
ddiff --git a/server/providers/responses.js b/server/providers/responses.js
iindex 1175ce1..90148b3 100644
---- a/server/providers/responses.js
++++ b/server/providers/responses.js
@@@ -12,7 +12,7 @@
* step-down json_schema -> json_object -> prose for gateways that lack it. No embeddings or
* transcription: those stay with the `openai` kind.
*/
--const { ProviderError, postJson } = require('./common');
++const { ProviderError, postJson, schemaInstruction } = require('./common');
const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
const TEXT_OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'];
@@@ -35,7 +35,9 @@ function buildBody({ model, system, messages, image, json, maxTokens, temperatur
return { role: m.role, content: inputContent(m.content) };
});
const body = { model, input };
-- const sysText = (system || []).map(x => x.text).join('\n\n');
++ // Stepped down from json_schema: the schema travels in the instructions instead.
++ const hint = json && jsonMode !== 'schema' ? schemaInstruction(json) : '';
++ const sysText = [...(system || []).map(x => x.text), hint].filter(Boolean).join('\n\n');
if (sysText) body.instructions = sysText;
if (isReasoningModel(model)) {
body.max_output_tokens = Math.max(maxTokens, 256) + 512;
ddiff --git a/server/workflows/products.js b/server/workflows/products.js
iindex b14a76f..fa6d772 100644
---- a/server/workflows/products.js
++++ b/server/workflows/products.js
@@@ -47,7 +47,7 @@ const templates = [
'Offer as submitted:\n{{json offer}}\n\n'),
productTemplate('coupons.extract_coupon', 'Coupons: extract coupons', 'Extract every coupon code the sources state, with its restrictions and expiry. If the expiry is not stated, expires_at is null and expiry_known is false. Never mark a code as working; validity is decided by reports, not by you.', 'coupons.extract',
'{{#merchant}}Merchant: {{json merchant}}\n\n{{/merchant}}'),
-- productTemplate('trade.summarize_market_context', 'Trade: market context', 'Summarize the sourced market and filing context for the instrument. Every observation carries the observation time from its source. Flag sources older than the staleness window as stale. Informational only: no advice, no predictions, no recommendations.', 'trade.summarize',
++ productTemplate('trade.summarize_market_context', 'Trade: market context', 'Summarize the sourced market and filing context for the instrument. Return: summary (two to four plain sentences on what the latest figures and filings show, such as the latest period\'s numbers next to the same period a year before, exactly as the sources state them); observations (one per notable figure or filing: text, observed_at as the period end or date the source gives, and citations); stale_sources (the numbers of sources older than the staleness window); disclaimer (exactly "Informational only; not investment advice."); citations; gaps. Do arithmetic only on numbers the sources give, and say what you compared. Every observation carries the observation time from its source. Informational only: no advice, no predictions, no recommendations, no price targets.', 'trade.summarize',
'Instrument: {{json instrument}}{{#stale_after_hours}}\nStaleness window: {{stale_after_hours}} hours{{/stale_after_hours}}\n\n'),
productTemplate('codes.generate_docs', 'Codes: generate documentation', 'Write developer documentation for the project from the provided source files: an overview and sections in Markdown. Cite the files each section describes. Do not document behaviour the files do not show.', 'codes.generate_docs',
'Project: {{project}}{{#audience}}\nAudience: {{audience}}{{/audience}}\n\n'),
ddiff --git a/test/providers.test.js b/test/providers.test.js
iindex ee8001d..e2177a6 100644
---- a/test/providers.test.js
++++ b/test/providers.test.js
@@@ -41,14 +41,19 @@ t.test('Anthropic body: cached system blocks, image block, forced tool for JSON'
t.test('OpenAI adapter steps down json_schema -> json_object on a gateway that refuses it', async () => {
const seen = [];
++ const systems = [];
const srv = await seamServer((body) => {
seen.push(body.response_format && body.response_format.type);
++ systems.push((body.messages.find((m) => m.role === 'system') || {}).content || '');
if (body.response_format && body.response_format.type === 'json_schema') return { status: 400, body: { error: { message: 'response_format json_schema is not supported' } } };
return { choices: [{ message: { content: '{"a":1}' } }], usage: { prompt_tokens: 7, completion_tokens: 3, prompt_tokens_details: { cached_tokens: 2 } }, model: 'm' };
});
const a = createOpenAiProvider({ key: 'o', base_url: srv.url, capabilities: [] }, { apiKey: 'k' });
const r = await a.chat(req({ json: { name: 'r', schema: { type: 'object' } } }));
assert.deepStrictEqual(seen, ['json_schema', 'json_object']);
++ // The schema the gateway refused travels in the instructions of the json_object request, not before.
++ assert.ok(!systems[0].includes('JSON Schema'), 'not while structured output carries it');
++ assert.ok(systems[1].includes('matches this JSON Schema exactly') && systems[1].includes('{"type":"object"}'), systems[1]);
assert.strictEqual(r.text, '{"a":1}');
assert.deepStrictEqual(r.usage, { input: 7, output: 3, cached: 2 });
assert.ok(a.supports('embed') && a.supports('vision'));