Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ These are non-negotiable. Violating them breaks the build or introduces bugs.
1. **Dynamic imports only in registry** — All providers must use dynamic imports inside factory functions in `providerRegistry.ts`. Static imports create circular dependencies.
2. **Types in canonical location** — All type definitions go in `src/lib/types/`. Never create type files inside feature subdirectories.
3. **Gemini tools + JSON schema are mutually exclusive** — Google AI Studio and Vertex **Gemini** models cannot use tools and `structuredOutput` with a JSON schema simultaneously (a Gemini API limitation). This does **not** apply to Vertex **Claude** models, which support both at once — the exclusion is gated on `isGeminiProvider` in `structuredOutputPolicy.ts`, not on the Vertex provider as a whole. Providers that reject the combination at runtime (e.g. Groq) are detected via `isToolsSchemaConflictError` and transparently retried without structured output. Regardless of provider, `generate({ schema })` is guaranteed to return valid JSON in `content` plus a parsed `structuredData` object (see `coerceJsonToSchema`).
- **Huge-text / truncation:** the native Claude paths (Vertex+Claude, direct Anthropic) must default `max_tokens` to the model's real output ceiling via `resolveClaudeMaxTokens` (Sonnet 4.x → 64K, Opus 4.x → 32K), **never** the legacy hard-coded 4096 that silently truncated large structured responses mid-JSON. The direct Anthropic non-streaming path also passes an explicit request `timeout` so the SDK's "streaming is required for long requests" pre-flight guard doesn't reject a large `max_tokens`. When output still hits the cap, truncation is surfaced — not silent: `coerceJsonToSchema` returns `{ repaired, truncated }`, and `GenerateResult` exposes `jsonRepaired` / `jsonTruncated` (set when `finishReason==="length"` or the recovered JSON came from an unclosed span) plus a WARN log.
- **Huge-text / truncation:** the native Claude paths (Vertex+Claude, direct Anthropic) must default `max_tokens` to the model's real output ceiling via `resolveClaudeMaxTokens` (Sonnet 4.x → 64K, Opus 4.x → 32K), **never** the legacy hard-coded 4096 that silently truncated large structured responses mid-JSON. The direct Anthropic non-streaming path also passes an explicit request `timeout` so the SDK's "streaming is required for long requests" pre-flight guard doesn't reject a large `max_tokens`. When output still hits the cap, truncation is surfaced — not silent: `coerceJsonToSchema` returns `{ repaired, truncated }`, and `GenerateResult` exposes `jsonRepaired` / `jsonTruncated` (set when `finishReason==="length"` or the recovered JSON came from an unclosed span) plus a WARN log. A truncated response must still yield a **partial object** — never a raw string: `coerceJsonToSchema` prefers the candidate starting at the document's real root (so a bracket pair scraped from inside a string value can't win), and backs off to the last completed field when jsonrepair can't close the span. That recovered `structuredData` is a **plain object, not necessarily a schema-valid one** — when the response was cut short it may be partial — and `jsonTruncated` is set in exactly that case (`jsonRepaired` when the JSON had to be recovered), so a caller can distinguish a salvaged object from a complete one. A caller that needs schema-valid data must check `jsonTruncated` before trusting the object; a caller that wants best-effort data can use it as is. Only schema-rejected **scalar** roots (e.g. a raw string under an object schema) are suppressed via `schemaAccepts`, since they carry no recoverable structure.
4. **CLI ≠ SDK** — CLI can use manual MCP connections; the SDK cannot. Keep concerns separate.
5. **Backward compatibility** — Public SDK API must not break existing callers.
6. **`formatProviderError` must return, never throw** — Any provider error formatter must return the error object, not throw it.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@
"test:file-detector-magic-bytes": "npx tsx test/continuous-test-suite-file-detector-magic-bytes.ts",
"test:json": "npx tsx test/continuous-test-suite-json.ts",
"test:json-e2e": "npx tsx test/continuous-test-suite-json-e2e.ts",
"test:coerce-truncation": "npx tsx test/continuous-test-suite-coerce-truncation.ts",
"test:workflow": "npx tsx test/continuous-test-suite-workflow.ts",
"test:hitl": "npx tsx test/continuous-test-suite-hitl.ts",
"test:analytics": "npx tsx test/continuous-test-suite-analytics.ts",
Expand Down
65 changes: 49 additions & 16 deletions src/lib/core/modules/GenerationHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,11 @@ import {
isToolsSchemaConflictError,
isToolsSchemaExclusionInForce,
} from "./structuredOutputPolicy.js";
import { coerceJsonToSchema } from "../../utils/json/coerce.js";
import {
coerceJsonToSchema,
recoverScalarRoot,
schemaAccepts,
} from "../../utils/json/coerce.js";
import { convertZodToJsonSchema } from "../../utils/schemaConversion.js";
import type {
LanguageModel,
Expand Down Expand Up @@ -1133,9 +1137,9 @@ export class GenerationHandler {
}
return coerced.content;
}
try {
const scalar: unknown = JSON.parse(strippedText);
if (scalar === "") {
const scalar = recoverScalarRoot(strippedText, options.schema);
switch (scalar.kind) {
case "empty":
// A JSON-encoded empty string is an EMPTY completion, not a
// recovered scalar — normalize to a true empty ('' content, no
// structuredData) so callers' empty-response handling fires
Expand All @@ -1145,19 +1149,32 @@ export class GenerationHandler {
{ provider: this.providerName, model: this.modelName },
);
return "";
}
if (scalar !== null && scalar !== undefined) {
structuredData = scalar;
case "accepted":
// A JSON scalar root is only real structured data when the caller's
// schema actually accepts it. Under an OBJECT schema a recovered
// string/number is the raw completion in disguise (the shape a
// truncated response degrades to) — publishing it would hand the
// caller a `structuredData` that violates the schema they passed.
structuredData = scalar.value;
return strippedText;
case "rejected":
logger.warn(
"[GenerationHandler] recovered a JSON scalar the requested schema rejects; leaving structuredData unset",
{
provider: this.providerName,
model: this.modelName,
scalarType: typeof scalar.value,
},
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return strippedText;
case "nullish":
case "not-json":
logger.warn(
"[GenerationHandler] schema requested but no JSON could be recovered from model text; returning raw text",
{ provider: this.providerName, model: this.modelName },
);
return strippedText;
}
} catch {
// not JSON at all — fall through to raw text + WARN
}
logger.warn(
"[GenerationHandler] schema requested but no JSON could be recovered from model text; returning raw text",
{ provider: this.providerName, model: this.modelName },
);
return strippedText;
};
if (useStructuredOutput) {
try {
Expand All @@ -1173,7 +1190,23 @@ export class GenerationHandler {
const rawTextEcho =
typeof experimentalOutput === "string" &&
experimentalOutput === (generateResult.text ?? "");
if (experimentalOutput !== undefined && !rawTextEcho) {
// The equality check above only catches an EXACT echo. On a multi-step
// or truncated turn the echo can differ from `text` (a different step's
// text, a fence, trailing whitespace), and a raw string would then be
// published as `structuredData` under an object schema — the "returned
// a string instead of the schema object" failure. A string is trusted
// as structured output ONLY when the caller's schema accepts it
// (string-root schemas keep working); otherwise it is coerced like any
// other raw model text.
const untrustedStringOutput =
typeof experimentalOutput === "string" &&
!!options.schema &&
!schemaAccepts(options.schema, experimentalOutput);
if (
experimentalOutput !== undefined &&
!rawTextEcho &&
!untrustedStringOutput
) {
// AI-SDK already parsed + schema-validated the object. Expose it
// directly and serialise canonically — no hand-parsing needed.
structuredData = experimentalOutput;
Expand Down
152 changes: 99 additions & 53 deletions src/lib/neurolink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ import type {
AnalyticsData,
EvaluationData,
NeurolinkCredentials,
OptionalValidationSchema,
ProviderStatus,
TextGenerationOptions,
TextGenerationResult,
Expand Down Expand Up @@ -264,7 +265,11 @@ import {
} from "./utils/lifecycleCallbacks.js";
import { resolveLifecycleTimeoutMs } from "./utils/lifecycleTimeout.js";
import { cloneOptionsForCallIsolation } from "./utils/cloneOptions.js";
import { coerceJsonToSchema } from "./utils/json/coerce.js";
import {
coerceJsonToSchema,
recoverScalarRoot,
schemaAccepts,
} from "./utils/json/coerce.js";
// Factory processing imports
import {
createCleanStreamOptions,
Expand Down Expand Up @@ -5453,6 +5458,98 @@ Current user's request: ${currentInput}`;
return textOptions;
}

/**
* Provider-agnostic JSON recovery for schema requests. Structured-output
* enforcement makes valid JSON the overwhelming case; for every other
* provider path — including generate() overrides (Vertex, Anthropic,
* Bedrock, Google AI Studio) — object/array roots are recovered here via
* balanced-scan + jsonrepair and scalar JSON roots via plain JSON.parse,
* with the parsed value exposed as `structuredData`. If nothing JSON-shaped
* is recoverable (pure prose), the raw text is returned, `structuredData`
* stays undefined, and a WARN makes the case observable.
*
* Mutates `textResult` in place, and must run BEFORE the end-of-generation
* emits so event consumers see the same content/structuredData the caller
* receives.
*/
private recoverStructuredData(
textResult: TextGenerationResult,
schema: OptionalValidationSchema,
): void {
// A provider path that produced its own `structuredData` normally owns it.
// The one exception is a STRING the caller's schema rejects: that is the
// raw completion leaking through as structured output (the shape a
// truncated response degrades to), so re-run recovery over the text rather
// than handing back a value the declared schema forbids.
const structuredIsRejectedString =
typeof textResult.structuredData === "string" &&
!schemaAccepts(schema, textResult.structuredData);
if (
!schema ||
(textResult.structuredData !== undefined &&
!structuredIsRejectedString) ||
typeof textResult.content !== "string"
) {
return;
}
if (structuredIsRejectedString) {
textResult.structuredData = undefined;
}
const coerced = coerceJsonToSchema(textResult.content, schema);
if (coerced) {
textResult.content = coerced.content;
textResult.structuredData = coerced.structuredData;
if (coerced.repaired) {
textResult.jsonRepaired = true;
}
if (coerced.truncated) {
textResult.jsonTruncated = true;
}
return;
}
const scalar = recoverScalarRoot(textResult.content, schema);
switch (scalar.kind) {
case "empty":
// A JSON-encoded empty string is an EMPTY completion, not a recovered
// scalar — normalize to a true empty so callers' empty-response
// handling fires instead of a literal '""' reaching the user.
// `structuredData` stays undefined.
textResult.content = "";
logger.warn(
"[NeuroLink] schema requested but the model returned an empty JSON string; normalizing to empty content",
{ provider: textResult.provider, model: textResult.model },
);
break;
case "accepted":
// Only publish a scalar root the caller's schema actually accepts.
// Under an OBJECT schema a recovered string is the raw completion in
// disguise — the shape a truncated response degrades to — and exposing
// it hands the caller a `structuredData` that violates the schema they
// passed.
textResult.structuredData = scalar.value;
break;
case "rejected":
logger.warn(
"[NeuroLink] recovered a JSON scalar the requested schema rejects; leaving structuredData unset",
{
provider: textResult.provider,
model: textResult.model,
scalarType: typeof scalar.value,
},
);
break;
case "nullish":
// JSON null/undefined — no structured value to publish.
break;
case "not-json":
logger.warn(
"[NeuroLink] schema requested but no JSON could be recovered from model output; returning raw text",
{ provider: textResult.provider, model: textResult.model },
);
break;
}
}

private finalizeGenerateRequestResult(params: {
generateSpan: ReturnType<typeof tracers.sdk.startSpan>;
options: GenerateOptions;
Expand All @@ -5472,58 +5569,7 @@ Current user's request: ${currentInput}`;
startTime,
} = params;

// Provider-agnostic JSON coercion for schema requests. Structured-output
// enforcement makes valid JSON the overwhelming case; for every other
// provider path — including generate() overrides (Vertex, Anthropic,
// Bedrock, Google AI Studio) — object/array roots are recovered here via
// balanced-scan + jsonrepair and scalar JSON roots via plain JSON.parse,
// with the parsed value exposed as `structuredData`. If nothing
// JSON-shaped is recoverable (pure prose), the raw text is returned,
// `structuredData` stays undefined, and a WARN makes the case observable.
// Runs BEFORE the end-of-generation emits below so event consumers see
// the same coerced content/structuredData the caller receives.
if (
textOptions.schema &&
textResult.structuredData === undefined &&
typeof textResult.content === "string"
) {
const coerced = coerceJsonToSchema(
textResult.content,
textOptions.schema,
);
if (coerced) {
textResult.content = coerced.content;
textResult.structuredData = coerced.structuredData;
if (coerced.repaired) {
textResult.jsonRepaired = true;
}
if (coerced.truncated) {
textResult.jsonTruncated = true;
}
} else {
try {
const scalar: unknown = JSON.parse(textResult.content);
if (scalar === "") {
// A JSON-encoded empty string is an EMPTY completion, not a
// recovered scalar — normalize to a true empty so callers'
// empty-response handling fires instead of a literal '""'
// reaching the user. `structuredData` stays undefined.
textResult.content = "";
logger.warn(
"[NeuroLink] schema requested but the model returned an empty JSON string; normalizing to empty content",
{ provider: textResult.provider, model: textResult.model },
);
} else if (scalar !== null && scalar !== undefined) {
textResult.structuredData = scalar;
}
} catch {
logger.warn(
"[NeuroLink] schema requested but no JSON could be recovered from model output; returning raw text",
{ provider: textResult.provider, model: textResult.model },
);
}
}
}
this.recoverStructuredData(textResult, textOptions.schema);

// Surface truncation when a schema was requested: either the provider
// reported finishReason="length" or the recovered JSON came from an
Expand Down
17 changes: 16 additions & 1 deletion src/lib/providers/anthropic/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1566,18 +1566,24 @@ export class AnthropicProvider extends BaseProvider {

const content: Array<{ type: string } & Record<string, unknown>> = [];
let finalResultText: string | undefined;
// Text emitted in forced-json mode, kept only as a fallback (see below).
const jsonModeText: string[] = [];
let jsonToolAnswered = false;
for (const block of response.content) {
if (block.type === "thinking") {
content.push({ type: "reasoning", text: block.thinking });
} else if (block.type === "text") {
// In forced-json mode the payload arrives via the tool input, not
// text — pass text through only in normal mode.
if (!jsonTool) {
if (jsonTool) {
jsonModeText.push(block.text);
} else {
content.push({ type: "text", text: block.text });
}
} else if (block.type === "tool_use") {
if (jsonTool && block.name === jsonTool) {
// Unwrap the synthetic tool call back into text JSON.
jsonToolAnswered = true;
content.push({
type: "text",
text: stringifyToolInput(block.input),
Expand All @@ -1599,6 +1605,15 @@ export class AnthropicProvider extends BaseProvider {
}
}
}
// Forced-json mode normally drops text blocks because the payload rides
// in the synthetic tool's input. But when the response is cut short
// (stop_reason "max_tokens") the tool call can be missing entirely, and
// dropping the text would leave an EMPTY completion with nothing for
// coerceJsonToSchema to recover. Fall back to the text so a partial
// object can still be salvaged and flagged truncated.
if (jsonTool && !jsonToolAnswered && jsonModeText.length > 0) {
content.push({ type: "text", text: jsonModeText.join("") });
}

// final_result is terminal — parity with the native Claude-on-Vertex
// and Gemini loops, which break out of the tool loop the moment it
Expand Down
23 changes: 23 additions & 0 deletions src/lib/types/utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,3 +334,26 @@ export type JsonCoercionResult = {
*/
truncated: boolean;
};

/**
* Decision returned by `recoverScalarRoot`. Each caller applies it to its own
* result shape and logger prefix, preserving its existing warning behaviour:
*
* - `empty` — the text is a JSON-encoded empty string (an EMPTY
* completion, not a recovered scalar). Callers normalize to a
* true empty.
* - `accepted` — a scalar root the caller's schema accepts; safe to publish
* as `structuredData`.
* - `rejected` — a scalar root the caller's schema rejects (e.g. a raw
* string under an object schema — the shape a truncated
* response degrades to). Do NOT publish it.
* - `nullish` — the text is the JSON literals `null`/`undefined`; there is
* no structured value to publish.
* - `not-json` — the text is not JSON at all.
*/
export type ScalarRecoveryDecision =
| { kind: "empty" }
| { kind: "accepted"; value: unknown }
| { kind: "rejected"; value: unknown }
| { kind: "nullish" }
| { kind: "not-json" };
Loading
Loading