openzoo 0.48.66 → 0.48.68

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 (2) hide show
  1. package/lib/anthropic.js +44 -2
  2. package/package.json +1 -1
package/lib/anthropic.js CHANGED
@@ -96,7 +96,21 @@ export function anthropicToOpenAI(body) {
96
96
  }));
97
97
  if (!out.tools.length) delete out.tools;
98
98
  }
99
- if (body.tool_choice?.type === 'auto') out.tool_choice = 'auto';
99
+ // TOOL_CHOICE MAY NOT OUTLIVE TOOLS.
100
+ //
101
+ // The block above DROPS any tool without a name+input_schema — which is every
102
+ // server-side tool (web_search, computer, bash), because those carry no schema
103
+ // in the Anthropic shape. When that empties the list, `out.tools` is deleted.
104
+ // But tool_choice was set unconditionally, so the body went upstream with a
105
+ // choice and nothing to choose from.
106
+ //
107
+ // OBSERVED on grok-4.6: a Claude Code Web Search 400'd with "A tool_choice was
108
+ // set on the request but no tools were specified." Every provider rejects this
109
+ // shape; it just words it differently. Gate the choice on tools surviving.
110
+ const hasTools = Array.isArray(out.tools) && out.tools.length > 0;
111
+ if (!hasTools) {
112
+ delete out.tool_choice;
113
+ } else if (body.tool_choice?.type === 'auto') out.tool_choice = 'auto';
100
114
  else if (body.tool_choice?.type === 'any') out.tool_choice = 'required';
101
115
  else if (body.tool_choice?.type === 'tool') {
102
116
  out.tool_choice = { type: 'function', function: { name: body.tool_choice.name } };
@@ -115,6 +129,12 @@ export function openAIToAnthropic(data, requestedModel) {
115
129
  const msg = choice.message ?? {};
116
130
  const content = [];
117
131
  if (msg.content) content.push({ type: 'text', text: msg.content });
132
+ // Same reason as the streaming path: a refusal arrives with `content: null`
133
+ // and the sentence in `refusal`, so dropping it yields an empty message that
134
+ // reads as a broken model rather than as the answer it is.
135
+ else if (typeof msg.refusal === 'string' && msg.refusal.length) {
136
+ content.push({ type: 'text', text: msg.refusal });
137
+ }
118
138
  for (const t of msg.tool_calls ?? []) {
119
139
  let input = {};
120
140
  try { input = JSON.parse(t.function?.arguments || '{}'); } catch { input = {}; }
@@ -257,9 +277,31 @@ export function streamOpenAIToAnthropic(res, upstream, requestedModel, onReceipt
257
277
  // `ping` is in Anthropic's own SSE grammar and carries no content, so a
258
278
  // client either ignores it or treats it as liveness — never as text.
259
279
  if (!(typeof delta.content === 'string' && delta.content.length)
260
- && !(delta.tool_calls ?? []).length) {
280
+ && !(delta.tool_calls ?? []).length
281
+ && !(typeof delta.refusal === 'string' && delta.refusal.length)) {
261
282
  ev('ping', {});
262
283
  }
284
+ // A REFUSAL IS A RESPONSE. SHOW IT.
285
+ //
286
+ // Upstream returns refusals out-of-band: `finish_reason: "content_filter"`,
287
+ // `content: null`, and the actual sentence in `refusal`. We only ever read
288
+ // `delta.content`, so the whole turn translated to a VALID, EMPTY stream —
289
+ // message_start, message_stop, output_tokens 0 — and rendered as silence.
290
+ //
291
+ // OBSERVED: a security audit of the user's OWN exploited protocol returning
292
+ // nothing at all, repeatedly, and reading as "fable is broken" when the
293
+ // provider had in fact answered: "This request triggered restrictions on
294
+ // violative cyber content and was blocked under Anthropic's Usage Policy."
295
+ // Being told you were refused is actionable — you can rephrase, or route to
296
+ // another model. Being shown an empty box is not.
297
+ if (typeof delta.refusal === 'string' && delta.refusal.length) {
298
+ if (textIndex < 0) {
299
+ textIndex = nextIndex++;
300
+ ev('content_block_start', { index: textIndex, content_block: { type: 'text', text: '' } });
301
+ open.add(textIndex);
302
+ }
303
+ ev('content_block_delta', { index: textIndex, delta: { type: 'text_delta', text: delta.refusal } });
304
+ }
263
305
  if (typeof delta.content === 'string' && delta.content.length) {
264
306
  if (textIndex < 0) {
265
307
  textIndex = nextIndex++;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.48.66",
3
+ "version": "0.48.68",
4
4
  "description": "Local x402-paying proxy + MCP server for openzoo.fun — point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana and Base rails live; Robinhood experimental.",
5
5
  "license": "MIT",
6
6
  "type": "module",