openvibers/openvibe.ai Commits
Anthropic: the current Claude models get requests they accept (L33) (#220)
Changes
@@@ -11,17 +11,32 @@* ping). DeepSeek and MiniMax publish Anthropic-compatible endpoints, so the same adapter serves them at another* base_url.*/--const { postJson, ProviderError, deadline, describeAbort } = require('./common');++const { postJson, ProviderError, deadline, describeAbort, schemaInstruction } = require('./common');const { sseFrames } = require('./stream');const DEFAULT_BASE_URL = 'https://api.anthropic.com/v1';const TEXT_OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'];const MESSAGES_FIELDS = ['system', 'tools', 'tool_choice', 'stop_sequences', 'temperature', 'top_p', 'top_k', 'metadata'];--function buildBody({ model, system, messages, image, json, maxTokens, temperature }) {++/**++ * The current Claude models (4.6 and later, the 5 family): sampling settings (temperature, top_p, top_k) are refused++ * with a 400, a forced tool choice ({type: 'tool' | 'any'}) is refused on Fable 5.1, Opus 5.5 and Sonnet 5.5, JSON++ * comes from structured outputs (output_config.format) and depth from output_config.effort. Older models and the++ * Anthropic-format endpoints of other providers (DeepSeek, MiniMax) keep the shapes they take.++ */++const CURRENT_CLAUDE = /^claude-(fable|mythos|opus|sonnet|haiku)-(5|4-[6-9])\b/i;++const isCurrentClaude = (model) => CURRENT_CLAUDE.test(String(model || ''));++const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];++++/** jsonMode: 'format' (structured outputs), 'tool' (a forced tool, older models) or 'prose' (the schema in the system prompt). */++function buildBody({ model, system, messages, image, json, maxTokens, temperature, effort }, jsonMode) {++ const current = isCurrentClaude(model);++ const mode = json ? (jsonMode || (current ? 'format' : 'tool')) : null;const body = { model, max_tokens: Math.max(1, maxTokens) };-- if (system && system.length) {-- body.system = system.map(x => (x.cache ? { type: 'text', text: x.text, cache_control: { type: 'ephemeral' } } : { type: 'text', text: x.text }));++ const sys = [...(system || [])];++ if (mode === 'prose') sys.push({ text: schemaInstruction(json) });++ if (sys.length) {++ body.system = sys.map(x => (x.cache ? { type: 'text', text: x.text, cache_control: { type: 'ephemeral' } } : { type: 'text', text: x.text }));}body.messages = messages.map((m, i) => {const last = i === messages.length - 1;@@@ -31,8 +46,12 @@ function buildBody({ model, system, messages, image, json, maxTokens, temperaturif (last && image && m.role === 'user') content.push({ type: 'image', source: { type: 'base64', media_type: image.mediaType, data: image.base64 } });return { role: m.role, content };});-- if (temperature != null) body.temperature = temperature;-- if (json) {++ if (temperature != null && !current) body.temperature = temperature;++ const output = {};++ if (current && EFFORTS.includes(effort)) output.effort = effort;++ if (mode === 'format') output.format = { type: 'json_schema', schema: json.schema };++ if (Object.keys(output).length) body.output_config = output;++ if (mode === 'tool') {body.tools = [{ name: json.name || 'result', description: json.description || 'Return the result.', input_schema: json.schema }];body.tool_choice = { type: 'tool', name: json.name || 'result' };}@@@ -44,6 +63,12 @@ function messagesBody(req) {const body = { model: req.model, max_tokens: Math.max(1, Number(req.maxTokens) || 1024) };if (Array.isArray(req.messages)) body.messages = req.messages;for (const k of MESSAGES_FIELDS) if (req[k] !== undefined && req[k] !== null) body[k] = req[k];++ // A request written for another model, routed to a current Claude model: drop what it refuses rather than fail.++ if (isCurrentClaude(req.model)) {++ for (const k of ['temperature', 'top_p', 'top_k']) delete body[k];++ const tc = body.tool_choice;++ if (tc && (tc.type === 'tool' || tc.type === 'any')) body.tool_choice = { type: 'auto', ...(tc.disable_parallel_tool_use ? { disable_parallel_tool_use: true } : {}) };++ }return body;}@@@ -113,12 +138,21 @@ function createAnthropicProvider(record, { apiKey = '', fetchImpl = globalThis.f: { 'anthropic-version': '2023-06-01' });async function chat(req) {-- const j = await postJson(`${baseUrl}/messages`, headers(), buildBody(req), { signal: req.signal, timeoutMs: req.timeoutMs, fetchImpl });++ // Structured output steps down when an endpoint refuses it: output_config.format (or the forced tool) -> the++ // schema in the system prompt, whose prose JSON the engine repairs.++ let jsonMode = null, j;++ for (;;) {++ try { j = await postJson(`${baseUrl}/messages`, headers(), buildBody(req, jsonMode), { signal: req.signal, timeoutMs: req.timeoutMs, fetchImpl }); break; } catch (e) {++ if (req.json && jsonMode !== 'prose' && e instanceof ProviderError && e.status === 400 && /output_config|format|schema|tool_choice|tool/i.test(e.message)) { jsonMode = 'prose'; continue; }++ throw e;++ }++ }const content = j.content || [];let text = content.filter(c => c.type === 'text').map(c => c.text).join('').trim();let jsonOut = null;const tool = content.find(c => c.type === 'tool_use');if (tool && tool.input) { jsonOut = tool.input; if (!text) text = JSON.stringify(tool.input); }++ if (!jsonOut && req.json && (!jsonMode || jsonMode === 'format') && isCurrentClaude(req.model)) { try { jsonOut = JSON.parse(text); } catch { /* the engine repairs it */ } }const u = j.usage || {};const cached = u.cache_read_input_tokens || 0;return { text, json: jsonOut, model: j.model || req.model, usage: { input: (u.input_tokens || 0) + cached + (u.cache_creation_input_tokens || 0), output: u.output_tokens || 0, cached } };@@@ -176,4 +210,4 @@ function createAnthropicProvider(record, { apiKey = '', fetchImpl = globalThis.freturn adapter;}--module.exports = { createAnthropicProvider, buildBody, messagesBody, DEFAULT_BASE_URL };++module.exports = { createAnthropicProvider, buildBody, messagesBody, isCurrentClaude, DEFAULT_BASE_URL };@@@ -44,7 +44,7 @@ function rateLimited(err) {}/** The model a person's credential uses when they stored none, per provider (executeWithCredential). */--const CREDENTIAL_DEFAULT_MODEL = { anthropic: 'claude-haiku-4-5-20251001', openai: 'gpt-4o-mini', deepseek: 'deepseek-chat', minimax: 'MiniMax-M2', openrouter: 'openrouter/auto' };++const CREDENTIAL_DEFAULT_MODEL = { anthropic: 'claude-haiku-5-5', openai: 'gpt-4o-mini', deepseek: 'deepseek-chat', minimax: 'MiniMax-M2', openrouter: 'openrouter/auto' };function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, credentialFetch = null, machineModels = null, bench = null }) {// A person's own machine as their model (credential provider node, server/machines.js).@@@ -39,6 +39,46 @@ t.test('Anthropic body: cached system blocks, image block, forced tool for JSON'assert.strictEqual(b.tool_choice.name, 'r');});++t.test('Anthropic body for the current Claude models: no sampling, structured outputs, effort (L33)', () => {++ const { messagesBody, isCurrentClaude } = require('../server/providers/anthropic');++ for (const m of ['claude-opus-5-5', 'claude-sonnet-5-5', 'claude-haiku-5-5', 'claude-fable-5-1', 'claude-opus-4-8', 'claude-sonnet-4-6']) assert.ok(isCurrentClaude(m), m);++ for (const m of ['claude-haiku-4-5', 'claude-3-5-sonnet-latest', 'deepseek-chat', 'MiniMax-M2']) assert.ok(!isCurrentClaude(m), m);++ const b = anthropicBody({ ...req({ model: 'claude-sonnet-5-5', effort: 'low' }), json: { name: 'r', schema: { type: 'object' } } });++ assert.strictEqual(b.temperature, undefined, 'a current Claude model refuses sampling settings');++ assert.deepStrictEqual(b.output_config, { effort: 'low', format: { type: 'json_schema', schema: { type: 'object' } } });++ assert.strictEqual(b.tool_choice, undefined, 'and a forced tool');++ const old = anthropicBody({ ...req({ model: 'claude-haiku-4-5', effort: 'low' }), json: { name: 'r', schema: { type: 'object' } } });++ assert.strictEqual(old.temperature, 0.5);++ assert.strictEqual(old.tool_choice.name, 'r');++ assert.strictEqual(old.output_config, undefined, 'older models keep the forced tool and their sampling');++ const prose = anthropicBody({ ...req({ model: 'claude-opus-5-5' }), json: { name: 'r', schema: { type: 'object' } } }, 'prose');++ assert.ok(prose.output_config === undefined && prose.system.some((x) => /JSON Schema/.test(x.text)), 'the step-down puts the schema in the system prompt');++ const pass = messagesBody({ model: 'claude-opus-5-5', maxTokens: 100, messages: [], temperature: 0.2, top_p: 0.9, tool_choice: { type: 'tool', name: 'x', disable_parallel_tool_use: true } });++ assert.ok(pass.temperature === undefined && pass.top_p === undefined, "a caller's sampling is dropped for a model that refuses it");++ assert.deepStrictEqual(pass.tool_choice, { type: 'auto', disable_parallel_tool_use: true });++ const kept = messagesBody({ model: 'deepseek-chat', maxTokens: 100, messages: [], temperature: 0.2, tool_choice: { type: 'tool', name: 'x' } });++ assert.ok(kept.temperature === 0.2 && kept.tool_choice.type === 'tool', 'other endpoints get the request as written');++});++++t.test('Anthropic adapter: structured output, and the schema in the prompt when an endpoint refuses it', async () => {++ const seen = [];++ const srv = await seamServer((b) => {++ seen.push(b.output_config && b.output_config.format ? 'format' : 'prose');++ if (b.output_config && b.output_config.format) return { status: 400, body: { error: { message: 'output_config.format: schema not supported' } } };++ return { content: [{ type: 'text', text: '{"ok":true}' }], usage: { input_tokens: 5, output_tokens: 2 }, model: 'claude-sonnet-5-5' };++ });++ const a = createAnthropicProvider({ key: 'a', base_url: srv.url, capabilities: [] }, { apiKey: 'ak' });++ const r = await a.chat(req({ model: 'claude-sonnet-5-5', json: { name: 'r', schema: { type: 'object' } } }));++ assert.deepStrictEqual(seen, ['format', 'prose']);++ assert.strictEqual(r.text, '{"ok":true}');++ await srv.close();++ const ok = await seamServer(() => ({ content: [{ type: 'text', text: '{"a":1}' }], usage: { input_tokens: 1, output_tokens: 1 }, model: 'claude-opus-5-5' }));++ const b = createAnthropicProvider({ key: 'b', base_url: ok.url, capabilities: [] }, { apiKey: 'ak' });++ const r2 = await b.chat(req({ model: 'claude-opus-5-5', json: { name: 'r', schema: { type: 'object' } } }));++ assert.deepStrictEqual(r2.json, { a: 1 }, 'structured output is parsed');++ await ok.close();++});++t.test('OpenAI adapter steps down json_schema -> json_object on a gateway that refuses it', async () => {const seen = [];const systems = [];
@@ -11,17 +11,32 @@
@@ -11,17 +11,32 @@
* ping). DeepSeek and MiniMax publish Anthropic-compatible endpoints, so the same adapter serves them at another
* ping). DeepSeek and MiniMax publish Anthropic-compatible endpoints, so the same adapter serves them at another
* base_url.
* base_url.
*/
*/
-const { postJson, ProviderError, deadline, describeAbort } = require('./common');
+const { postJson, ProviderError, deadline, describeAbort, schemaInstruction } = require('./common');
const { sseFrames } = require('./stream');
const { sseFrames } = require('./stream');
const DEFAULT_BASE_URL = 'https://api.anthropic.com/v1';
const DEFAULT_BASE_URL = 'https://api.anthropic.com/v1';
const TEXT_OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'];
const TEXT_OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'];
const MESSAGES_FIELDS = ['system', 'tools', 'tool_choice', 'stop_sequences', 'temperature', 'top_p', 'top_k', 'metadata'];
const MESSAGES_FIELDS = ['system', 'tools', 'tool_choice', 'stop_sequences', 'temperature', 'top_p', 'top_k', 'metadata'];
-function buildBody({ model, system, messages, image, json, maxTokens, temperature }) {
+/**
+ * The current Claude models (4.6 and later, the 5 family): sampling settings (temperature, top_p, top_k) are refused
+ * with a 400, a forced tool choice ({type: 'tool' | 'any'}) is refused on Fable 5.1, Opus 5.5 and Sonnet 5.5, JSON
+ * comes from structured outputs (output_config.format) and depth from output_config.effort. Older models and the
+ * Anthropic-format endpoints of other providers (DeepSeek, MiniMax) keep the shapes they take.
+ */
+const CURRENT_CLAUDE = /^claude-(fable|mythos|opus|sonnet|haiku)-(5|4-[6-9])\b/i;
+const isCurrentClaude = (model) => CURRENT_CLAUDE.test(String(model || ''));
+const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
+
+/** jsonMode: 'format' (structured outputs), 'tool' (a forced tool, older models) or 'prose' (the schema in the system prompt). */
+function buildBody({ model, system, messages, image, json, maxTokens, temperature, effort }, jsonMode) {
+ const current = isCurrentClaude(model);
+ const mode = json ? (jsonMode || (current ? 'format' : 'tool')) : null;
const body = { model, max_tokens: Math.max(1, maxTokens) };
const body = { model, max_tokens: Math.max(1, maxTokens) };
- if (system && system.length) {
- body.system = system.map(x => (x.cache ? { type: 'text', text: x.text, cache_control: { type: 'ephemeral' } } : { type: 'text', text: x.text }));
+ const sys = [...(system || [])];
+ if (mode === 'prose') sys.push({ text: schemaInstruction(json) });
+ if (sys.length) {
+ body.system = sys.map(x => (x.cache ? { type: 'text', text: x.text, cache_control: { type: 'ephemeral' } } : { type: 'text', text: x.text }));
}
}
body.messages = messages.map((m, i) => {
body.messages = messages.map((m, i) => {
const last = i === messages.length - 1;
const last = i === messages.length - 1;
@@ -31,8 +46,12 @@ function buildBody({ model, system, messages, image, json, maxTokens, temperatur
@@ -31,8 +46,12 @@ function buildBody({ model, system, messages, image, json, maxTokens, temperatur
if (last && image && m.role === 'user') content.push({ type: 'image', source: { type: 'base64', media_type: image.mediaType, data: image.base64 } });
if (last && image && m.role === 'user') content.push({ type: 'image', source: { type: 'base64', media_type: image.mediaType, data: image.base64 } });
return { role: m.role, content };
return { role: m.role, content };
});
});
- if (temperature != null) body.temperature = temperature;
- if (json) {
+ if (temperature != null && !current) body.temperature = temperature;
+ const output = {};
+ if (current && EFFORTS.includes(effort)) output.effort = effort;
+ if (mode === 'format') output.format = { type: 'json_schema', schema: json.schema };
+ if (Object.keys(output).length) body.output_config = output;
+ if (mode === 'tool') {
body.tools = [{ name: json.name || 'result', description: json.description || 'Return the result.', input_schema: json.schema }];
body.tools = [{ name: json.name || 'result', description: json.description || 'Return the result.', input_schema: json.schema }];
body.tool_choice = { type: 'tool', name: json.name || 'result' };
body.tool_choice = { type: 'tool', name: json.name || 'result' };
}
}
@@ -44,6 +63,12 @@ function messagesBody(req) {
@@ -44,6 +63,12 @@ function messagesBody(req) {
const body = { model: req.model, max_tokens: Math.max(1, Number(req.maxTokens) || 1024) };
const body = { model: req.model, max_tokens: Math.max(1, Number(req.maxTokens) || 1024) };
if (Array.isArray(req.messages)) body.messages = req.messages;
if (Array.isArray(req.messages)) body.messages = req.messages;
for (const k of MESSAGES_FIELDS) if (req[k] !== undefined && req[k] !== null) body[k] = req[k];
for (const k of MESSAGES_FIELDS) if (req[k] !== undefined && req[k] !== null) body[k] = req[k];
+ // A request written for another model, routed to a current Claude model: drop what it refuses rather than fail.
+ if (isCurrentClaude(req.model)) {
+ for (const k of ['temperature', 'top_p', 'top_k']) delete body[k];
+ const tc = body.tool_choice;
+ if (tc && (tc.type === 'tool' || tc.type === 'any')) body.tool_choice = { type: 'auto', ...(tc.disable_parallel_tool_use ? { disable_parallel_tool_use: true } : {}) };
+ }
return body;
return body;
}
}
@@ -113,12 +138,21 @@ function createAnthropicProvider(record, { apiKey = '', fetchImpl = globalThis.f
@@ -113,12 +138,21 @@ function createAnthropicProvider(record, { apiKey = '', fetchImpl = globalThis.f
: { 'anthropic-version': '2023-06-01' });
: { 'anthropic-version': '2023-06-01' });
async function chat(req) {
async function chat(req) {
- const j = await postJson(`${baseUrl}/messages`, headers(), buildBody(req), { signal: req.signal, timeoutMs: req.timeoutMs, fetchImpl });
+ // Structured output steps down when an endpoint refuses it: output_config.format (or the forced tool) -> the
+ // schema in the system prompt, whose prose JSON the engine repairs.
+ let jsonMode = null, j;
+ for (;;) {
+ try { j = await postJson(`${baseUrl}/messages`, headers(), buildBody(req, jsonMode), { signal: req.signal, timeoutMs: req.timeoutMs, fetchImpl }); break; } catch (e) {
+ if (req.json && jsonMode !== 'prose' && e instanceof ProviderError && e.status === 400 && /output_config|format|schema|tool_choice|tool/i.test(e.message)) { jsonMode = 'prose'; continue; }
+ throw e;
+ }
+ }
const content = j.content || [];
const content = j.content || [];
let text = content.filter(c => c.type === 'text').map(c => c.text).join('').trim();
let text = content.filter(c => c.type === 'text').map(c => c.text).join('').trim();
let jsonOut = null;
let jsonOut = null;
const tool = content.find(c => c.type === 'tool_use');
const tool = content.find(c => c.type === 'tool_use');
if (tool && tool.input) { jsonOut = tool.input; if (!text) text = JSON.stringify(tool.input); }
if (tool && tool.input) { jsonOut = tool.input; if (!text) text = JSON.stringify(tool.input); }
+ if (!jsonOut && req.json && (!jsonMode || jsonMode === 'format') && isCurrentClaude(req.model)) { try { jsonOut = JSON.parse(text); } catch { /* the engine repairs it */ } }
const u = j.usage || {};
const u = j.usage || {};
const cached = u.cache_read_input_tokens || 0;
const cached = u.cache_read_input_tokens || 0;
return { text, json: jsonOut, model: j.model || req.model, usage: { input: (u.input_tokens || 0) + cached + (u.cache_creation_input_tokens || 0), output: u.output_tokens || 0, cached } };
return { text, json: jsonOut, model: j.model || req.model, usage: { input: (u.input_tokens || 0) + cached + (u.cache_creation_input_tokens || 0), output: u.output_tokens || 0, cached } };
@@ -176,4 +210,4 @@ function createAnthropicProvider(record, { apiKey = '', fetchImpl = globalThis.f
@@ -176,4 +210,4 @@ function createAnthropicProvider(record, { apiKey = '', fetchImpl = globalThis.f
return adapter;
return adapter;
}
}
-module.exports = { createAnthropicProvider, buildBody, messagesBody, DEFAULT_BASE_URL };
+module.exports = { createAnthropicProvider, buildBody, messagesBody, isCurrentClaude, DEFAULT_BASE_URL };
@@ -44,7 +44,7 @@ function rateLimited(err) {
@@ -44,7 +44,7 @@ function rateLimited(err) {
}
}
/** The model a person's credential uses when they stored none, per provider (executeWithCredential). */
/** The model a person's credential uses when they stored none, per provider (executeWithCredential). */
-const CREDENTIAL_DEFAULT_MODEL = { anthropic: 'claude-haiku-4-5-20251001', openai: 'gpt-4o-mini', deepseek: 'deepseek-chat', minimax: 'MiniMax-M2', openrouter: 'openrouter/auto' };
+const CREDENTIAL_DEFAULT_MODEL = { anthropic: 'claude-haiku-5-5', openai: 'gpt-4o-mini', deepseek: 'deepseek-chat', minimax: 'MiniMax-M2', openrouter: 'openrouter/auto' };
function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, credentialFetch = null, machineModels = null, bench = null }) {
function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, credentialFetch = null, machineModels = null, bench = null }) {
// A person's own machine as their model (credential provider node, server/machines.js).
// A person's own machine as their model (credential provider node, server/machines.js).
@@ -39,6 +39,46 @@ t.test('Anthropic body: cached system blocks, image block, forced tool for JSON'
@@ -39,6 +39,46 @@ t.test('Anthropic body: cached system blocks, image block, forced tool for JSON'
assert.strictEqual(b.tool_choice.name, 'r');
assert.strictEqual(b.tool_choice.name, 'r');
});
});
+t.test('Anthropic body for the current Claude models: no sampling, structured outputs, effort (L33)', () => {
+ const { messagesBody, isCurrentClaude } = require('../server/providers/anthropic');
+ for (const m of ['claude-opus-5-5', 'claude-sonnet-5-5', 'claude-haiku-5-5', 'claude-fable-5-1', 'claude-opus-4-8', 'claude-sonnet-4-6']) assert.ok(isCurrentClaude(m), m);
+ for (const m of ['claude-haiku-4-5', 'claude-3-5-sonnet-latest', 'deepseek-chat', 'MiniMax-M2']) assert.ok(!isCurrentClaude(m), m);
+ const b = anthropicBody({ ...req({ model: 'claude-sonnet-5-5', effort: 'low' }), json: { name: 'r', schema: { type: 'object' } } });
+ assert.strictEqual(b.temperature, undefined, 'a current Claude model refuses sampling settings');
+ assert.deepStrictEqual(b.output_config, { effort: 'low', format: { type: 'json_schema', schema: { type: 'object' } } });
+ assert.strictEqual(b.tool_choice, undefined, 'and a forced tool');
+ const old = anthropicBody({ ...req({ model: 'claude-haiku-4-5', effort: 'low' }), json: { name: 'r', schema: { type: 'object' } } });
+ assert.strictEqual(old.temperature, 0.5);
+ assert.strictEqual(old.tool_choice.name, 'r');
+ assert.strictEqual(old.output_config, undefined, 'older models keep the forced tool and their sampling');
+ const prose = anthropicBody({ ...req({ model: 'claude-opus-5-5' }), json: { name: 'r', schema: { type: 'object' } } }, 'prose');
+ assert.ok(prose.output_config === undefined && prose.system.some((x) => /JSON Schema/.test(x.text)), 'the step-down puts the schema in the system prompt');
+ const pass = messagesBody({ model: 'claude-opus-5-5', maxTokens: 100, messages: [], temperature: 0.2, top_p: 0.9, tool_choice: { type: 'tool', name: 'x', disable_parallel_tool_use: true } });
+ assert.ok(pass.temperature === undefined && pass.top_p === undefined, "a caller's sampling is dropped for a model that refuses it");
+ assert.deepStrictEqual(pass.tool_choice, { type: 'auto', disable_parallel_tool_use: true });
+ const kept = messagesBody({ model: 'deepseek-chat', maxTokens: 100, messages: [], temperature: 0.2, tool_choice: { type: 'tool', name: 'x' } });
+ assert.ok(kept.temperature === 0.2 && kept.tool_choice.type === 'tool', 'other endpoints get the request as written');
+});
+
+t.test('Anthropic adapter: structured output, and the schema in the prompt when an endpoint refuses it', async () => {
+ const seen = [];
+ const srv = await seamServer((b) => {
+ seen.push(b.output_config && b.output_config.format ? 'format' : 'prose');
+ if (b.output_config && b.output_config.format) return { status: 400, body: { error: { message: 'output_config.format: schema not supported' } } };
+ return { content: [{ type: 'text', text: '{"ok":true}' }], usage: { input_tokens: 5, output_tokens: 2 }, model: 'claude-sonnet-5-5' };
+ });
+ const a = createAnthropicProvider({ key: 'a', base_url: srv.url, capabilities: [] }, { apiKey: 'ak' });
+ const r = await a.chat(req({ model: 'claude-sonnet-5-5', json: { name: 'r', schema: { type: 'object' } } }));
+ assert.deepStrictEqual(seen, ['format', 'prose']);
+ assert.strictEqual(r.text, '{"ok":true}');
+ await srv.close();
+ const ok = await seamServer(() => ({ content: [{ type: 'text', text: '{"a":1}' }], usage: { input_tokens: 1, output_tokens: 1 }, model: 'claude-opus-5-5' }));
+ const b = createAnthropicProvider({ key: 'b', base_url: ok.url, capabilities: [] }, { apiKey: 'ak' });
+ const r2 = await b.chat(req({ model: 'claude-opus-5-5', json: { name: 'r', schema: { type: 'object' } } }));
+ assert.deepStrictEqual(r2.json, { a: 1 }, 'structured output is parsed');
+ await ok.close();
+});
+
t.test('OpenAI adapter steps down json_schema -> json_object on a gateway that refuses it', async () => {
t.test('OpenAI adapter steps down json_schema -> json_object on a gateway that refuses it', async () => {
const seen = [];
const seen = [];
const systems = [];
const systems = [];