@animalabs/membrane 0.5.68 → 0.5.69

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 (52) hide show
  1. package/dist/formatters/anthropic-xml.d.ts.map +1 -1
  2. package/dist/formatters/anthropic-xml.js +14 -8
  3. package/dist/formatters/anthropic-xml.js.map +1 -1
  4. package/dist/formatters/native.d.ts +3 -0
  5. package/dist/formatters/native.d.ts.map +1 -1
  6. package/dist/formatters/native.js +70 -14
  7. package/dist/formatters/native.js.map +1 -1
  8. package/dist/membrane.d.ts.map +1 -1
  9. package/dist/membrane.js +40 -12
  10. package/dist/membrane.js.map +1 -1
  11. package/dist/providers/anthropic-tool-schema.d.ts +43 -0
  12. package/dist/providers/anthropic-tool-schema.d.ts.map +1 -0
  13. package/dist/providers/anthropic-tool-schema.js +165 -0
  14. package/dist/providers/anthropic-tool-schema.js.map +1 -0
  15. package/dist/providers/anthropic.d.ts.map +1 -1
  16. package/dist/providers/anthropic.js +102 -4
  17. package/dist/providers/anthropic.js.map +1 -1
  18. package/dist/providers/gemini.d.ts.map +1 -1
  19. package/dist/providers/gemini.js +15 -0
  20. package/dist/providers/gemini.js.map +1 -1
  21. package/dist/providers/index.d.ts +1 -0
  22. package/dist/providers/index.d.ts.map +1 -1
  23. package/dist/providers/index.js +1 -0
  24. package/dist/providers/index.js.map +1 -1
  25. package/dist/providers/openrouter.d.ts +6 -0
  26. package/dist/providers/openrouter.d.ts.map +1 -1
  27. package/dist/providers/openrouter.js +87 -4
  28. package/dist/providers/openrouter.js.map +1 -1
  29. package/dist/types/provider.d.ts +8 -0
  30. package/dist/types/provider.d.ts.map +1 -1
  31. package/dist/types/request.d.ts +8 -0
  32. package/dist/types/request.d.ts.map +1 -1
  33. package/dist/utils/image-media.d.ts +54 -0
  34. package/dist/utils/image-media.d.ts.map +1 -0
  35. package/dist/utils/image-media.js +135 -0
  36. package/dist/utils/image-media.js.map +1 -0
  37. package/dist/utils/tool-parser.d.ts.map +1 -1
  38. package/dist/utils/tool-parser.js +15 -9
  39. package/dist/utils/tool-parser.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/formatters/anthropic-xml.ts +13 -8
  42. package/src/formatters/native.ts +70 -14
  43. package/src/membrane.ts +43 -12
  44. package/src/providers/anthropic-tool-schema.ts +194 -0
  45. package/src/providers/anthropic.ts +111 -6
  46. package/src/providers/gemini.ts +14 -0
  47. package/src/providers/index.ts +2 -0
  48. package/src/providers/openrouter.ts +94 -5
  49. package/src/types/provider.ts +7 -1
  50. package/src/types/request.ts +8 -0
  51. package/src/utils/image-media.ts +150 -0
  52. package/src/utils/tool-parser.ts +14 -9
@@ -29,6 +29,19 @@ import type {
29
29
  StreamEmission,
30
30
  } from './types.js';
31
31
  import { normalizeToolPairs, mergeConsecutiveRoles } from './normalize-tool-pairs.js';
32
+ import { isAcceptedImageMediaType, strippedImagePlaceholder } from '../utils/image-media.js';
33
+
34
+ /** Index of the last content block that can carry cache_control. Anthropic
35
+ * rejects cache_control on thinking / redacted_thinking blocks, so a cache
36
+ * breakpoint must attach to the last NON-thinking block. Returns -1 when the
37
+ * message has only thinking blocks (the breakpoint is then skipped). */
38
+ function lastCacheableBlockIndex(blocks: Array<Record<string, unknown>>): number {
39
+ for (let k = blocks.length - 1; k >= 0; k--) {
40
+ const t = blocks[k]?.type as string | undefined;
41
+ if (t !== 'thinking' && t !== 'redacted_thinking') return k;
42
+ }
43
+ return -1;
44
+ }
32
45
 
33
46
  // ============================================================================
34
47
  // Configuration
@@ -243,8 +256,9 @@ export class NativeFormatter implements PrefillFormatter {
243
256
  if (hasCacheMarker && hasCacheMarker(message, i) && cacheControl && providerMessages.length > 0) {
244
257
  const prevMsg = providerMessages[providerMessages.length - 1]!;
245
258
  const prevContent = Array.isArray(prevMsg.content) ? prevMsg.content as Record<string, unknown>[] : [];
246
- if (prevContent.length > 0) {
247
- prevContent[prevContent.length - 1]!.cache_control = cacheControl;
259
+ const prevIdx = lastCacheableBlockIndex(prevContent);
260
+ if (prevIdx >= 0) {
261
+ prevContent[prevIdx]!.cache_control = cacheControl;
248
262
  markedBreakpoints++;
249
263
  }
250
264
  }
@@ -253,8 +267,11 @@ export class NativeFormatter implements PrefillFormatter {
253
267
 
254
268
  // cacheBreakpoint: cache up to and INCLUDING this message — tag last block
255
269
  if (message.cacheBreakpoint && cacheControl && content.length > 0) {
256
- (content[content.length - 1] as Record<string, unknown>).cache_control = cacheControl;
257
- markedBreakpoints++;
270
+ const bpIdx = lastCacheableBlockIndex(content as Record<string, unknown>[]);
271
+ if (bpIdx >= 0) {
272
+ (content[bpIdx] as Record<string, unknown>).cache_control = cacheControl;
273
+ markedBreakpoints++;
274
+ }
258
275
  }
259
276
  }
260
277
 
@@ -349,6 +366,25 @@ export class NativeFormatter implements PrefillFormatter {
349
366
  // PRIVATE HELPERS
350
367
  // ==========================================================================
351
368
 
369
+ /** Replace API-unacceptable image blocks nested in tool_result content with
370
+ * text placeholders. Non-array content passes through untouched. */
371
+ private static sanitizeToolResultContent(content: unknown): unknown {
372
+ if (!Array.isArray(content)) return content;
373
+ return content.map((item) => {
374
+ if (
375
+ item &&
376
+ typeof item === 'object' &&
377
+ (item as { type?: string }).type === 'image'
378
+ ) {
379
+ const src = (item as { source?: { media_type?: string } }).source;
380
+ if (!isAcceptedImageMediaType(src?.media_type)) {
381
+ return strippedImagePlaceholder(src?.media_type);
382
+ }
383
+ }
384
+ return item;
385
+ });
386
+ }
387
+
352
388
  private convertContent(
353
389
  content: ContentBlock[],
354
390
  participant: string,
@@ -371,19 +407,39 @@ export class NativeFormatter implements PrefillFormatter {
371
407
  result.push(textBlock);
372
408
  } else if (block.type === 'image') {
373
409
  if (block.source.type === 'base64') {
374
- const imageBlock: Record<string, unknown> = {
375
- type: 'image',
410
+ if (!isAcceptedImageMediaType(block.source.mediaType)) {
411
+ // Unacceptable media type (e.g. image/svg): degrade to a text
412
+ // placeholder instead of poisoning the whole request.
413
+ result.push(strippedImagePlaceholder(block.source.mediaType));
414
+ } else {
415
+ const imageBlock: Record<string, unknown> = {
416
+ type: 'image',
417
+ source: {
418
+ type: 'base64',
419
+ media_type: block.source.mediaType,
420
+ data: block.source.data,
421
+ },
422
+ };
423
+ // Preserve sourceUrl for providers that use URL-as-text (Gemini 3.x)
424
+ if (block.sourceUrl) {
425
+ imageBlock.sourceUrl = block.sourceUrl;
426
+ }
427
+ result.push(imageBlock);
428
+ }
429
+ }
430
+ } else if (block.type === 'audio') {
431
+ // Pass audio through in the same shape as images — the provider
432
+ // adapters convert it (Gemini → inlineData, OpenRouter → input_audio).
433
+ // Whether a given model accepts audio is the provider/model's concern.
434
+ if (block.source.type === 'base64') {
435
+ result.push({
436
+ type: 'audio',
376
437
  source: {
377
438
  type: 'base64',
378
439
  media_type: block.source.mediaType,
379
440
  data: block.source.data,
380
441
  },
381
- };
382
- // Preserve sourceUrl for providers that use URL-as-text (Gemini 3.x)
383
- if (block.sourceUrl) {
384
- imageBlock.sourceUrl = block.sourceUrl;
385
- }
386
- result.push(imageBlock);
442
+ });
387
443
  }
388
444
  } else if (block.type === 'tool_use') {
389
445
  result.push({
@@ -396,7 +452,7 @@ export class NativeFormatter implements PrefillFormatter {
396
452
  result.push({
397
453
  type: 'tool_result',
398
454
  tool_use_id: block.toolUseId,
399
- content: block.content,
455
+ content: NativeFormatter.sanitizeToolResultContent(block.content),
400
456
  is_error: block.isError,
401
457
  });
402
458
  } else if (block.type === 'thinking') {
@@ -414,7 +470,7 @@ export class NativeFormatter implements PrefillFormatter {
414
470
  } else if (block.type === 'redacted_thinking') {
415
471
  // Pass through verbatim (carries encrypted data field)
416
472
  result.push({ ...(block as unknown as Record<string, unknown>) });
417
- } else if (block.type === 'document' || block.type === 'audio') {
473
+ } else if (block.type === 'document') {
418
474
  hasUnsupportedMedia = true;
419
475
  }
420
476
  }
package/src/membrane.ts CHANGED
@@ -55,6 +55,11 @@ import { AnthropicXmlFormatter } from './formatters/anthropic-xml.js';
55
55
  import { normalizeToolPairs, mergeConsecutiveRoles } from './formatters/normalize-tool-pairs.js';
56
56
  import { YieldingStreamImpl } from './yielding-stream.js';
57
57
  import { calculateCost } from './utils/cost.js';
58
+ import {
59
+ isAcceptedImageMediaType,
60
+ strippedImagePlaceholder,
61
+ shedImagesToFitByteBudget, assertWithinByteBudget,
62
+ } from './utils/image-media.js';
58
63
  import { getDefaultPricing } from './registry/default-pricing.js';
59
64
 
60
65
  // ============================================================================
@@ -1045,19 +1050,26 @@ export class Membrane {
1045
1050
  content.push({ ...(block as unknown as Record<string, unknown>) });
1046
1051
  } else if (block.type === 'image') {
1047
1052
  if (block.source.type === 'base64') {
1048
- const imageBlock: Record<string, unknown> = {
1049
- type: 'image',
1050
- source: {
1051
- type: 'base64',
1052
- media_type: block.source.mediaType,
1053
- data: block.source.data,
1054
- },
1055
- };
1056
- // Preserve sourceUrl for providers that use URL-as-text (Gemini 3.x)
1057
- if (block.sourceUrl) {
1058
- imageBlock.sourceUrl = block.sourceUrl;
1053
+ if (!isAcceptedImageMediaType(block.source.mediaType)) {
1054
+ // API-unacceptable media type (e.g. image/svg): degrade to a
1055
+ // loud text placeholder instead of poisoning the whole request
1056
+ // (one bad stored block otherwise 400s every compile forever).
1057
+ content.push(strippedImagePlaceholder(block.source.mediaType));
1058
+ } else {
1059
+ const imageBlock: Record<string, unknown> = {
1060
+ type: 'image',
1061
+ source: {
1062
+ type: 'base64',
1063
+ media_type: block.source.mediaType,
1064
+ data: block.source.data,
1065
+ },
1066
+ };
1067
+ // Preserve sourceUrl for providers that use URL-as-text (Gemini 3.x)
1068
+ if (block.sourceUrl) {
1069
+ imageBlock.sourceUrl = block.sourceUrl;
1070
+ }
1071
+ content.push(imageBlock);
1059
1072
  }
1060
- content.push(imageBlock);
1061
1073
  }
1062
1074
  }
1063
1075
  }
@@ -1132,6 +1144,14 @@ export class Membrane {
1132
1144
  // Anthropic requires temperature=1 when extended thinking is enabled
1133
1145
  const temperature = alwaysOnThinking ? undefined : (thinking ? 1 : request.config.temperature);
1134
1146
 
1147
+ // Byte-wall policy point (see transformRequest): loud failure unless the
1148
+ // caller explicitly owns image loss.
1149
+ if (request.shedOversizeImages) {
1150
+ shedImagesToFitByteBudget(mergedMessages, undefined, 'buildNativeToolRequest');
1151
+ } else {
1152
+ assertWithinByteBudget(mergedMessages, undefined, 'buildNativeToolRequest');
1153
+ }
1154
+
1135
1155
  return {
1136
1156
  model: request.config.model,
1137
1157
  maxTokens: request.config.maxTokens,
@@ -1369,6 +1389,17 @@ export class Membrane {
1369
1389
  prefillUserMessage: request.prefillUserMessage,
1370
1390
  });
1371
1391
 
1392
+ // Byte-wall policy point (2026-07-12): transformRequest serves BOTH
1393
+ // complete() and the streaming path through EVERY adapter. Oversize
1394
+ // requests FAIL LOUDLY here, before the API round-trip, unless the
1395
+ // caller explicitly owns image loss via `shedOversizeImages` (and the
1396
+ // shed itself reports at error grade). No silent transport mutation.
1397
+ if (request.shedOversizeImages) {
1398
+ shedImagesToFitByteBudget(buildResult.messages, undefined, 'transformRequest');
1399
+ } else {
1400
+ assertWithinByteBudget(buildResult.messages, undefined, 'transformRequest');
1401
+ }
1402
+
1372
1403
  const providerRequest = {
1373
1404
  ...this.getBaseProviderParams(request.config),
1374
1405
  messages: buildResult.messages,
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Anthropic tool-schema normalization
3
+ *
4
+ * MCP permits a tool's input JSON Schema to have a root-level `oneOf` /
5
+ * `anyOf` / `allOf` (a union of alternative argument shapes). The Anthropic
6
+ * API does not: `input_schema` must be a single object-type schema, and a
7
+ * root-level combinator is rejected with 400
8
+ * ("input_schema does not support oneOf, allOf, or anyOf at the top level").
9
+ * One such tool 400s the entire inference — same philosophy as
10
+ * `normalize-tool-pairs`: repair at the wire boundary instead of letting a
11
+ * producer-side quirk kill the turn.
12
+ *
13
+ * `flattenRootSchemaUnion` rewrites the common case — every union variant is
14
+ * itself an object schema — into a single object schema:
15
+ *
16
+ * - `properties` is the merge of all variants' properties (first wins on
17
+ * key collision).
18
+ * - `required`: for `allOf` (intersective — every variant applies) the
19
+ * union of the variants' required lists; for `oneOf`/`anyOf`
20
+ * (alternatives) only keys required by *every* variant stay required.
21
+ * - A short note enumerating the alternative argument groups is appended
22
+ * to the description so the model still sees the union intent.
23
+ * - Variant-level `additionalProperties: false` is dropped: the merged
24
+ * object is a permissive superset of the alternatives, and `false`
25
+ * could reject payloads valid under one of the original variants.
26
+ *
27
+ * If any variant is not object-shaped (e.g. a root `oneOf` of a string and
28
+ * an object), the union cannot be merged into properties; we fall back to a
29
+ * permissive object schema carrying the serialized union in the description.
30
+ * Degenerate, but it does not 400 — the tool stays callable and the model
31
+ * sees the accepted shapes.
32
+ *
33
+ * Nested combinators (inside `properties`, array `items`, etc.) are legal
34
+ * for Anthropic and are left untouched; only the root is rewritten.
35
+ */
36
+
37
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
38
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
39
+ }
40
+
41
+ function isMergeableObjectVariant(variant: Record<string, unknown>): boolean {
42
+ return (
43
+ variant.type === 'object' ||
44
+ (variant.type === undefined && isPlainObject(variant.properties))
45
+ );
46
+ }
47
+
48
+ function stringRequired(value: unknown): string[] {
49
+ return Array.isArray(value)
50
+ ? value.filter((entry): entry is string => typeof entry === 'string')
51
+ : [];
52
+ }
53
+
54
+ const ROOT_UNION_KEYS = ['oneOf', 'anyOf', 'allOf'] as const;
55
+
56
+ /** Max length of the description we synthesize on the fallback path. */
57
+ const MAX_FALLBACK_DESCRIPTION = 4000;
58
+
59
+ /**
60
+ * Rewrite a root-level `oneOf`/`anyOf`/`allOf` in a tool input schema into a
61
+ * single Anthropic-acceptable object schema. Returns the input unchanged
62
+ * (same reference) when there is nothing to repair, so callers can cheaply
63
+ * detect whether a rewrite happened.
64
+ */
65
+ export function flattenRootSchemaUnion(schema: unknown): unknown {
66
+ if (!isPlainObject(schema)) return schema;
67
+
68
+ // A root may carry more than one combinator at once (e.g. `{ oneOf, anyOf }`).
69
+ // These are sibling keywords: a valid instance must satisfy every one of
70
+ // them (an implicit `allOf` across the combinators). Process *all* present
71
+ // keys — handling only the first would strip the others' variants (the
72
+ // destructuring below removes all three keys), silently losing their
73
+ // properties and required lists.
74
+ const presentKeys = ROOT_UNION_KEYS.filter(
75
+ (key) => Array.isArray(schema[key]) && (schema[key] as unknown[]).length > 0,
76
+ );
77
+ if (presentKeys.length === 0) return schema;
78
+
79
+ const combinators = presentKeys.map((key) => {
80
+ const raw = schema[key] as unknown[];
81
+ return { key, raw, variants: raw.filter(isPlainObject) };
82
+ });
83
+ const rawVariantsAll: unknown[] = combinators.flatMap(({ raw }) => raw);
84
+
85
+ // Keep every root key except the combinators themselves.
86
+ const { oneOf: _oneOf, anyOf: _anyOf, allOf: _allOf, ...rest } = schema;
87
+
88
+ const allMergeable = combinators.every(
89
+ ({ raw, variants }) =>
90
+ variants.length === raw.length && variants.every(isMergeableObjectVariant),
91
+ );
92
+
93
+ if (allMergeable) {
94
+ // Common case: every variant of every present combinator is an object
95
+ // schema — merge them all.
96
+ const properties: Record<string, unknown> = isPlainObject(rest.properties)
97
+ ? { ...rest.properties }
98
+ : {};
99
+ for (const { variants } of combinators) {
100
+ for (const variant of variants) {
101
+ if (isPlainObject(variant.properties)) {
102
+ for (const [key, propSchema] of Object.entries(variant.properties)) {
103
+ if (!(key in properties)) properties[key] = propSchema;
104
+ }
105
+ }
106
+ }
107
+ }
108
+
109
+ // Per-combinator required semantics: `allOf` unions its variants' required
110
+ // lists (every variant applies); `oneOf`/`anyOf` keep only keys required by
111
+ // every variant (alternatives). Across combinators they all apply at once,
112
+ // so the effective required set is the union of the per-combinator results.
113
+ const mergedRequired = combinators.flatMap(({ key, variants }) => {
114
+ const variantRequired = variants.map((variant) => stringRequired(variant.required));
115
+ return key === 'allOf'
116
+ ? [...new Set(variantRequired.flat())]
117
+ : variantRequired.reduce(
118
+ (acc, req) => acc.filter((k) => req.includes(k)),
119
+ variantRequired[0] ?? [],
120
+ );
121
+ });
122
+ const required = [
123
+ ...new Set([...stringRequired(rest.required), ...mergedRequired]),
124
+ ].filter((key) => key in properties);
125
+
126
+ const {
127
+ properties: _properties,
128
+ required: _required,
129
+ additionalProperties: _additionalProperties,
130
+ ...restSansObjectKeys
131
+ } = rest;
132
+
133
+ const result: Record<string, unknown> = {
134
+ ...restSansObjectKeys,
135
+ type: 'object',
136
+ properties,
137
+ };
138
+ if (required.length > 0) result.required = required;
139
+
140
+ // For alternatives, preserve the union intent in prose so the model
141
+ // still knows the arguments come in groups (one line per combinator).
142
+ const noteParts = combinators
143
+ .filter(({ key, variants }) => key !== 'allOf' && variants.length > 1)
144
+ .map(({ variants }) => {
145
+ const groups = variants
146
+ .map((variant) => {
147
+ const req = stringRequired(variant.required);
148
+ return req.length > 0 ? `(${req.join(', ')})` : '(no required fields)';
149
+ })
150
+ .join(' | ');
151
+ return `Provide one of the following argument groups: ${groups}.`;
152
+ });
153
+ if (noteParts.length > 0) {
154
+ const note = noteParts.join('\n');
155
+ result.description =
156
+ typeof result.description === 'string' && result.description.length > 0
157
+ ? `${result.description}\n${note}`
158
+ : note;
159
+ }
160
+
161
+ return result;
162
+ }
163
+
164
+ // Fallback: at least one variant (across the present combinators) is not an
165
+ // object schema (or not a schema at all). Emit a permissive object schema and
166
+ // carry the union(s) into the description so the intent survives.
167
+ const label = presentKeys.join('/');
168
+ let note: string;
169
+ try {
170
+ note = `Accepts one of the following input shapes (flattened from a root-level ${label}): ${JSON.stringify(rawVariantsAll)}`;
171
+ } catch {
172
+ note = `Accepts one of ${rawVariantsAll.length} alternative input shapes (root-level ${label} flattened).`;
173
+ }
174
+ const baseDescription =
175
+ typeof rest.description === 'string' && rest.description.length > 0
176
+ ? `${rest.description}\n${note}`
177
+ : note;
178
+
179
+ // Spread `rest` (already sans the combinator keys) so sibling definitions —
180
+ // `$defs`/`definitions` — survive. Without this, a variant serialized into
181
+ // the description as `{"$ref":"#/definitions/A"}` would point at a definition
182
+ // that no longer exists anywhere in the tool. The mergeable path preserves
183
+ // `restSansObjectKeys` for the same reason; the fallback extends the courtesy.
184
+ return {
185
+ ...rest,
186
+ type: 'object',
187
+ properties: isPlainObject(rest.properties) ? rest.properties : {},
188
+ additionalProperties: true,
189
+ description:
190
+ baseDescription.length > MAX_FALLBACK_DESCRIPTION
191
+ ? baseDescription.slice(0, MAX_FALLBACK_DESCRIPTION)
192
+ : baseDescription,
193
+ };
194
+ }
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import Anthropic, { type ClientOptions } from '@anthropic-ai/sdk';
6
+ import { assertWithinByteBudget, shedImagesToFitByteBudget } from '../utils/image-media.js';
6
7
  import type {
7
8
  ProviderAdapter,
8
9
  ProviderRequest,
@@ -20,6 +21,52 @@ import {
20
21
  serverError,
21
22
  abortError,
22
23
  } from '../types/index.js';
24
+ import { flattenRootSchemaUnion } from './anthropic-tool-schema.js';
25
+
26
+ // ============================================================================
27
+ // Model capability gates
28
+ // ============================================================================
29
+
30
+ /**
31
+ * Models that reject the sampling parameters (`temperature`, `top_p`,
32
+ * `top_k`) with a 400 invalid_request_error — which Membrane classifies as
33
+ * non-retryable, so a single stray `temperature` kills the whole turn.
34
+ * Mirrors the `noTemperatureSupport` gate in the OpenAI provider.
35
+ *
36
+ * This is the always-on-thinking / reasoning-forward tier, which removes the
37
+ * sampling parameters from the API surface entirely (Sonnet 5 rejects only
38
+ * non-default values). Everything else — Haiku 4.5, Sonnet 4.6, Opus 4.6 and
39
+ * older — ACCEPTS `temperature`, so it must NOT be listed here.
40
+ *
41
+ * - Opus 4.7 / Opus 4.8 / Sonnet 5 / Fable 5 / Mythos 5 / Mythos preview:
42
+ * documented 400 on any sampling parameter.
43
+ *
44
+ * NB: claude-haiku-4-5 was previously listed here on the strength of a single
45
+ * "observed 400 in production when temperature is sent" anecdote. Haiku 4.5
46
+ * documentably supports `temperature`; the production 400 was almost certainly
47
+ * the `extra`-params bypass fixed in this same PR (a sampling param smuggled
48
+ * through `extra` and re-inserted after the gate — 400s on any model), not a
49
+ * capability of Haiku. Listing it silently discarded a valid parameter on the
50
+ * most common cheap model, so it has been removed.
51
+ *
52
+ * Prefix-matched, so dated snapshots (e.g. claude-opus-4-8-20251001) are
53
+ * covered. Keep this list updated as models launch.
54
+ */
55
+ const NO_TEMPERATURE_MODELS = [
56
+ 'claude-opus-4-7',
57
+ 'claude-opus-4-8',
58
+ 'claude-sonnet-5',
59
+ 'claude-fable-5',
60
+ 'claude-mythos-5',
61
+ 'claude-mythos-preview',
62
+ ];
63
+
64
+ /**
65
+ * Check if a model doesn't support custom sampling parameters
66
+ */
67
+ function noTemperatureSupport(model: string): boolean {
68
+ return NO_TEMPERATURE_MODELS.some(prefix => model.startsWith(prefix));
69
+ }
23
70
 
24
71
  // ============================================================================
25
72
  // Adapter Configuration
@@ -326,6 +373,13 @@ export class AnthropicAdapter implements ProviderAdapter {
326
373
  };
327
374
  });
328
375
 
376
+ // Byte-wall INVARIANT at the last exit before the SDK (2026-07-12): the
377
+ // policy decision (shed with explicit opt-in, or fail loudly) happens
378
+ // upstream at the request-build sites. Reaching this point oversize means
379
+ // a compile path bypassed the policy — throw with the breakdown rather
380
+ // than silently mutate or eat a 413 round-trip.
381
+ assertWithinByteBudget(sanitizedMessages, undefined, 'anthropic-provider');
382
+
329
383
  const params: Anthropic.MessageCreateParams = {
330
384
  model: request.model,
331
385
  max_tokens: request.maxTokens || this.defaultMaxTokens,
@@ -342,17 +396,42 @@ export class AnthropicAdapter implements ProviderAdapter {
342
396
  }
343
397
  }
344
398
 
345
- if (request.temperature !== undefined) {
399
+ // Sampling-parameter gates:
400
+ // - Some models reject temperature/top_p/top_k outright with a 400
401
+ // (see NO_TEMPERATURE_MODELS) — strip rather than let the whole
402
+ // inference die on a non-retryable invalid_request_error.
403
+ // - Extended thinking rejects custom temperature/top_k on every model
404
+ // (only the defaults are accepted while thinking is on) — strip those
405
+ // too when a thinking config is present and not disabled.
406
+ const stripSampling = noTemperatureSupport(request.model);
407
+ // Thinking can arrive top-level OR smuggled through `extra` — the same
408
+ // `Object.assign(params, rest)` below installs `extra.thinking` into the
409
+ // request AFTER this gate ran. Resolve from both sources so an enabled
410
+ // thinking config strips sampling params no matter where it came from;
411
+ // otherwise `extra: { thinking, temperature }` reproduces the exact 400
412
+ // this gate exists to prevent (same bug class as the extra-sampling bypass,
413
+ // one field over).
414
+ const extraThinking = (request.extra as { thinking?: { type?: string } } | undefined)?.thinking;
415
+ const thinkingConfig = request.thinking ?? extraThinking;
416
+ const thinkingOn = thinkingConfig !== undefined && thinkingConfig.type !== 'disabled';
417
+
418
+ if (request.temperature !== undefined && !stripSampling && !thinkingOn) {
346
419
  params.temperature = request.temperature;
347
420
  }
348
421
 
349
422
  // Anthropic API rejects requests with both temperature and top_p set.
350
423
  // When both are provided, prefer temperature (more commonly tuned) and drop top_p.
351
- if (request.topP !== undefined && request.temperature === undefined) {
424
+ // With thinking on, top_p is only accepted in [0.95, 1] strip values below.
425
+ if (
426
+ request.topP !== undefined &&
427
+ params.temperature === undefined &&
428
+ !stripSampling &&
429
+ (!thinkingOn || request.topP >= 0.95)
430
+ ) {
352
431
  params.top_p = request.topP;
353
432
  }
354
433
 
355
- if (request.topK !== undefined) {
434
+ if (request.topK !== undefined && !stripSampling && !thinkingOn) {
356
435
  params.top_k = request.topK;
357
436
  }
358
437
 
@@ -361,17 +440,43 @@ export class AnthropicAdapter implements ProviderAdapter {
361
440
  }
362
441
 
363
442
  if (request.tools && request.tools.length > 0) {
364
- params.tools = request.tools as Anthropic.Tool[];
443
+ // MCP allows a root-level oneOf/anyOf/allOf in a tool's input schema,
444
+ // but the Anthropic API rejects it ("input_schema does not support
445
+ // oneOf, allOf, or anyOf at the top level") — one bad tool 400s the
446
+ // entire inference. Flatten such roots into a single object schema
447
+ // before shipping (see anthropic-tool-schema.ts).
448
+ params.tools = (request.tools as Anthropic.Tool[]).map(tool => {
449
+ const inputSchema = (tool as { input_schema?: unknown }).input_schema;
450
+ const flattened = flattenRootSchemaUnion(inputSchema);
451
+ return flattened === inputSchema
452
+ ? tool
453
+ : ({ ...tool, input_schema: flattened } as Anthropic.Tool);
454
+ });
365
455
  }
366
456
 
367
457
  // Handle extended thinking
368
- if ((request as any).thinking) {
369
- (params as any).thinking = (request as any).thinking;
458
+ if (request.thinking) {
459
+ (params as any).thinking = request.thinking;
370
460
  }
371
461
 
372
462
  // Apply extra params, excluding internal membrane fields
373
463
  if (request.extra) {
374
464
  const { normalizedMessages, prompt, ...rest } = request.extra as Record<string, unknown>;
465
+ // Sampling params passed through `extra` must obey the same gates as the
466
+ // top-level ones. Otherwise a caller passing e.g. `extra: { temperature }`
467
+ // for a reject-list model (or under extended thinking) would re-insert the
468
+ // stripped value here — via Object.assign, after the gate above — and
469
+ // reproduce the exact non-retryable 400 this stripping is meant to prevent.
470
+ if (stripSampling || thinkingOn) {
471
+ delete rest.temperature;
472
+ delete rest.top_k;
473
+ // top_p is accepted in [0.95, 1] while thinking is on, but never when
474
+ // the model rejects sampling params outright.
475
+ const extraTopP = rest.top_p;
476
+ if (stripSampling || typeof extraTopP !== 'number' || extraTopP < 0.95) {
477
+ delete rest.top_p;
478
+ }
479
+ }
375
480
  Object.assign(params, rest);
376
481
  }
377
482
 
@@ -428,6 +428,20 @@ export class GeminiAdapter implements ProviderAdapter {
428
428
  },
429
429
  });
430
430
  }
431
+ } else if (block.type === 'audio') {
432
+ // Audio input → Gemini inlineData (same mechanism as images). This is
433
+ // pure plumbing: whether a given model accepts/understands audio is the
434
+ // model's concern (the caller decides what to send). Common MIME types:
435
+ // audio/mp3, audio/wav, audio/ogg, audio/flac.
436
+ const source = block.source;
437
+ if (source?.type === 'base64' && source.data) {
438
+ parts.push({
439
+ inlineData: {
440
+ mimeType: source.mediaType ?? source.media_type ?? 'audio/mpeg',
441
+ data: source.data,
442
+ },
443
+ });
444
+ }
431
445
  } else if (block.type === 'tool_use') {
432
446
  parts.push({
433
447
  functionCall: {
@@ -9,6 +9,8 @@ export {
9
9
  type AnthropicAdapterConfig,
10
10
  } from './anthropic.js';
11
11
 
12
+ export { flattenRootSchemaUnion } from './anthropic-tool-schema.js';
13
+
12
14
  export {
13
15
  OpenRouterAdapter,
14
16
  toOpenRouterMessages,