Commit Diff


commit - 4a93e0de0e87b979672e0c5ca321485d1ee4a16f
commit + c46c1d5a37ed2a2f3626a2b10708eab0864e4795
blob - ce34704401519986ee51a3cb2370b366b5ff2d8e
blob + 0752a8746db3bf46705bf8d2ad79828b4509338e
--- regress/anthropic/anthropic_test.c
+++ regress/anthropic/anthropic_test.c
@@ -432,6 +432,7 @@ struct capture {
 	char		 tool_name[64];
 	char		 stop[64];
 	char		 err_type[64];
+	enum provider_error_category error_category;
 	int		 n_text, n_toolbegin, n_usage, n_done, n_error;
 	int64_t		 in_tokens, out_tokens, cache_read, cache_write;
 };
@@ -471,6 +472,7 @@ on_event(const struct astream_event *ev, void *arg)
 		break;
 	case ASTREAM_ERROR:
 		strlcpy(c->err_type, ev->err_type, sizeof(c->err_type));
+		c->error_category = ev->error_category;
 		c->n_error++;
 		break;
 	}
@@ -596,6 +598,7 @@ test_stream_error(void)
 	CHECK(anthropic_stream_feed(s, doc, sizeof(doc) - 1) == -1);
 	CHECK(c.n_error == 1);
 	CHECK(strcmp(c.err_type, "overloaded_error") == 0);
+	CHECK(c.error_category == PROVIDER_ERROR_OTHER);
 	CHECK(anthropic_stream_error(s)[0] != '\0');
 	/* the stream is terminal after an error */
 	CHECK(anthropic_stream_feed(s, doc, sizeof(doc) - 1) == -1);
blob - 509f8d4740d072eea5ffd48d487799572573190a
blob + 4dc039173c274085210510e34846c7c51a195b8d
--- regress/generation/generation_test.c
+++ regress/generation/generation_test.c
@@ -395,12 +395,62 @@ test_usage_retry_and_provider_error(void)
 	CHECK(generation_take(g, &r) == 0);
 	CHECK(r.outcome == GENERATION_PROVIDER_ERROR && r.assistant == NULL &&
 	    r.invalid_cause == GENERATION_INVALID_NONE &&
+	    r.provider_error_category == PROVIDER_ERROR_OTHER &&
+	    r.provider_content_accepted == 1 &&
 	    r.synthesized_text.data == NULL && r.synthesized_text.len == 0 &&
 	    r.data_used == 3 && strcmp(r.reason, "provider broke") == 0);
 	generation_free(g);
 }
 
 static void
+test_provider_error_category_and_content(void)
+{
+	struct generation	*g;
+	struct generation_result r = {0};
+	struct generation_usage	 usage = { 1, 2, 3, 4 };
+
+	/* Usage, retry notices, and empty deltas are not Provider content. */
+	g = generation_new(1);
+	CHECK(generation_feed(g, GENERATION_USAGE, &usage, sizeof(usage), NULL) ==
+	    GENERATION_MORE);
+	CHECK(generation_feed(g, GENERATION_RETRY, "retry", 5, NULL) ==
+	    GENERATION_MORE);
+	CHECK(generation_feed(g, GENERATION_TEXT, NULL, 0, NULL) ==
+	    GENERATION_MORE);
+	CHECK(generation_feed(g, GENERATION_CONTEXT_OVERFLOW, "too large", 9,
+	    NULL) == GENERATION_TERMINAL);
+	CHECK(generation_take(g, &r) == 0);
+	CHECK(r.outcome == GENERATION_PROVIDER_ERROR &&
+	    r.provider_error_category == PROVIDER_ERROR_CONTEXT_OVERFLOW &&
+	    r.provider_content_accepted == 0 && r.data_used == 0 &&
+	    strcmp(r.reason, "too large") == 0);
+	generation_free(g);
+
+	/* Any nonempty text accepted before the terminal is observable. */
+	memset(&r, 0, sizeof(r));
+	g = generation_new(1);
+	CHECK(generation_feed(g, GENERATION_TEXT, "x", 1, NULL) ==
+	    GENERATION_MORE);
+	CHECK(generation_feed(g, GENERATION_CONTEXT_OVERFLOW, "too large", 9,
+	    NULL) == GENERATION_TERMINAL);
+	CHECK(generation_take(g, &r) == 0);
+	CHECK(r.provider_error_category == PROVIDER_ERROR_CONTEXT_OVERFLOW &&
+	    r.provider_content_accepted == 1 && r.data_used == 1);
+	generation_free(g);
+
+	/* Opening a Tool call itself counts, before any arguments arrive. */
+	memset(&r, 0, sizeof(r));
+	g = generation_new(0);
+	CHECK(feed_begin(g, 0, "call", "read") == GENERATION_MORE);
+	CHECK(generation_feed(g, GENERATION_ERROR, "provider broke", 14, NULL) ==
+	    GENERATION_TERMINAL);
+	CHECK(generation_take(g, &r) == 0);
+	CHECK(r.provider_error_category == PROVIDER_ERROR_OTHER &&
+	    r.provider_content_accepted == 1 && r.tool_calls == 1);
+	generation_free(g);
+}
+
+static void
 test_usage_overflow_drains_to_terminal(void)
 {
 	struct generation	*g;
@@ -773,6 +823,7 @@ main(void)
 	test_malformed_arguments_are_never_accepted();
 	test_length_outcome_and_empty_placeholder();
 	test_usage_retry_and_provider_error();
+	test_provider_error_category_and_content();
 	test_usage_overflow_drains_to_terminal();
 	test_usage_values_are_bounded();
 	test_data_budget_drains_to_terminal();
blob - 8804c1922526d689f55fa0461c77863d72cae22e
blob + 6bb5f30365904a29172c625e2f2822b92cfc77cd
--- regress/openai/openai_test.c
+++ regress/openai/openai_test.c
@@ -264,6 +264,7 @@ struct capture {
 	char		 stop[64];
 	char		 err_type[64];
 	char		 err_message[128];
+	enum provider_error_category error_category;
 	int		 ndone;
 	int		 nerror;
 	int		 nusage;
@@ -330,6 +331,7 @@ on_event(const struct astream_event *ev, void *arg)
 		strlcpy(c->err_type, ev->err_type, sizeof(c->err_type));
 		strlcpy(c->err_message, ev->err_message,
 		    sizeof(c->err_message));
+		c->error_category = ev->error_category;
 		c->nerror++;
 		break;
 	}
@@ -436,12 +438,56 @@ test_stream_error(void)
 	CHECK(c.nerror == 1);
 	CHECK(strcmp(c.err_type, "rate_limit_error") == 0);
 	CHECK(strcmp(c.err_message, "slow down") == 0);
+	CHECK(c.error_category == PROVIDER_ERROR_OTHER);
 	CHECK(openai_stream_error(s)[0] != '\0');
 	openai_stream_free(s);
 	capture_free(&c);
 }
 
 static void
+test_stream_context_overflow_category(void)
+{
+	static const char exact[] =
+	    "data: {\"error\":{\"type\":\"invalid_request_error\","
+	    "\"code\":\"context_length_exceeded\","
+	    "\"message\":\"too many tokens\"}}\n\n";
+	static const char message_only[] =
+	    "data: {\"error\":{\"type\":\"invalid_request_error\","
+	    "\"code\":\"other\",\"message\":"
+	    "\"context_length_exceeded\"}}\n\n";
+	static const char near[] =
+	    "data: {\"error\":{\"type\":\"invalid_request_error\","
+	    "\"code\":\"context_length_exceeded_later\","
+	    "\"message\":\"too many tokens\"}}\n\n";
+	static const char numeric[] =
+	    "data: {\"error\":{\"type\":\"invalid_request_error\","
+	    "\"code\":400,\"message\":\"still readable\"}}\n\n";
+	const char		*docs[] = { exact, message_only, near, numeric };
+	size_t			 lens[] = { sizeof(exact) - 1,
+				    sizeof(message_only) - 1,
+				    sizeof(near) - 1,
+				    sizeof(numeric) - 1 };
+	enum provider_error_category want[] = {
+		PROVIDER_ERROR_CONTEXT_OVERFLOW,
+		PROVIDER_ERROR_OTHER,
+		PROVIDER_ERROR_OTHER,
+		PROVIDER_ERROR_OTHER
+	};
+	struct openai_stream	*s;
+	struct capture		 c;
+	size_t			 i;
+
+	for (i = 0; i < sizeof(docs) / sizeof(docs[0]); i++) {
+		capture_init(&c);
+		s = openai_stream_new(on_event, &c);
+		CHECK(openai_stream_feed(s, docs[i], lens[i]) == -1);
+		CHECK(c.nerror == 1 && c.error_category == want[i]);
+		openai_stream_free(s);
+		capture_free(&c);
+	}
+}
+
+static void
 test_stream_hostile(void)
 {
 	static const char wrong_type[] =
@@ -561,16 +607,43 @@ test_error_body(void)
 	static const char good[] =
 	    "{\"error\":{\"message\":\"bad model\","
 	    "\"type\":\"invalid_request_error\"}}";
+	static const char overflow[] =
+	    "{\"error\":{\"message\":\"too many tokens\","
+	    "\"type\":\"invalid_request_error\","
+	    "\"code\":\"context_length_exceeded\"}}";
+	static const char near[] =
+	    "{\"error\":{\"message\":\"context_length_exceeded\","
+	    "\"type\":\"invalid_request_error\","
+	    "\"code\":\"context_length_exceeded \"}}";
+	static const char numeric[] =
+	    "{\"error\":{\"message\":\"still readable\","
+	    "\"type\":\"invalid_request_error\",\"code\":400}}";
 	static const char nul[] =
 	    "{\"error\":{\"message\":\"bad\\u0000model\","
 	    "\"type\":\"invalid_request_error\"}}";
+	enum provider_error_category category;
 	char	 out[128];
 
-	CHECK(openai_error_body(good, sizeof(good) - 1, out,
-	    sizeof(out)) == 0);
+	CHECK(openai_error_body(good, sizeof(good) - 1, 400, out,
+	    sizeof(out), &category) == 0);
 	CHECK(strcmp(out, "invalid_request_error: bad model") == 0);
-	CHECK(openai_error_body(nul, sizeof(nul) - 1, out,
-	    sizeof(out)) == -1);
+	CHECK(category == PROVIDER_ERROR_OTHER);
+	CHECK(openai_error_body(overflow, sizeof(overflow) - 1, 400, out,
+	    sizeof(out), &category) == 0);
+	CHECK(category == PROVIDER_ERROR_CONTEXT_OVERFLOW);
+	CHECK(openai_error_body(overflow, sizeof(overflow) - 1, 413, out,
+	    sizeof(out), &category) == 0);
+	CHECK(category == PROVIDER_ERROR_OTHER);
+	CHECK(openai_error_body(near, sizeof(near) - 1, 400, out,
+	    sizeof(out), &category) == 0);
+	CHECK(category == PROVIDER_ERROR_OTHER);
+	CHECK(openai_error_body(numeric, sizeof(numeric) - 1, 400, out,
+	    sizeof(out), &category) == 0);
+	CHECK(category == PROVIDER_ERROR_OTHER &&
+	    strcmp(out, "invalid_request_error: still readable") == 0);
+	CHECK(openai_error_body(nul, sizeof(nul) - 1, 400, out,
+	    sizeof(out), &category) == -1);
+	CHECK(category == PROVIDER_ERROR_OTHER);
 }
 
 static void
@@ -604,6 +677,7 @@ main(void)
 	test_stream_whole();
 	test_stream_bytewise();
 	test_stream_error();
+	test_stream_context_overflow_category();
 	test_stream_hostile();
 	test_stream_bad_call_arguments();
 	test_error_body();
blob - 3aafeed64c4a0d64a3cfe44ea0bac160be1d9bed
blob + e492b090e91a1319c6bc4b95fcd6f020d9306a1a
--- regress/turn_mechanics/turn_mechanics_test.c
+++ regress/turn_mechanics/turn_mechanics_test.c
@@ -233,6 +233,12 @@ script_provider_error(struct scripted_generation *scri
 }
 
 static void
+script_context_overflow(struct scripted_generation *script, const char *error)
+{
+	script_bytes(script, GENERATION_CONTEXT_OVERFLOW, error, strlen(error));
+}
+
+static void
 script_usage(struct scripted_generation *script,
     const struct generation_usage *usage)
 {
@@ -1497,6 +1503,8 @@ test_subagent_conclusion_failure_preserves_fallback(vo
 	    result.invalid_cause == GENERATION_INVALID_NONE &&
 	    result.conclusion_failure == TURN_FAILURE_PROVIDER &&
 	    result.conclusion_invalid_cause == GENERATION_INVALID_NONE &&
+	    result.conclusion_provider_error_category == PROVIDER_ERROR_OTHER &&
+	    result.conclusion_provider_content_accepted == 0 &&
 	    strcmp(result.conclusion_error, "conclusion down") == 0 &&
 	    result.error[0] == '\0');
 	CHECK(result.assistant == fallback && result.data_used == 8 &&
@@ -1507,6 +1515,27 @@ test_subagent_conclusion_failure_preserves_fallback(vo
 	    f.request_tools[1] == TURN_TOOLS_DISABLED &&
 	    message_count(&f.accepted) == 3);
 	msglist_free(&f.accepted);
+
+	/* A typed overflow is terminal here too; Subagents never recover it. */
+	fixture_init(&f, &adapter, "q");
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_text(generation, "partial");
+	script_done(generation, "max_tokens");
+	generation = fixture_generation(&f, TURN_TOOLS_DISABLED);
+	script_context_overflow(generation, "conclusion too large");
+	options_init(&options, TURN_POLICY_SUBAGENT, 1, 2);
+	rc = turn_run(&adapter, &options, &result);
+	CHECK(rc == 0 && f.bad == 0 &&
+	    result.status == TURN_STATUS_CONCLUDED &&
+	    result.failure == TURN_FAILURE_NONE &&
+	    result.provider_error_category == PROVIDER_ERROR_NONE &&
+	    result.conclusion_failure == TURN_FAILURE_PROVIDER &&
+	    result.conclusion_provider_error_category ==
+	    PROVIDER_ERROR_CONTEXT_OVERFLOW &&
+	    result.conclusion_provider_content_accepted == 0 &&
+	    strcmp(result.conclusion_error, "conclusion too large") == 0 &&
+	    f.generation_next == 2);
+	msglist_free(&f.accepted);
 }
 
 static void
@@ -1716,6 +1745,8 @@ test_provider_and_adapter_errors_remain_distinct(void)
 	rc = turn_run(&adapter, &options, &result);
 	CHECK(rc == 0 && f.bad == 0 && result.status == TURN_STATUS_FAILED &&
 	    result.failure == TURN_FAILURE_PROVIDER && result.data_used == 8 &&
+	    result.provider_error_category == PROVIDER_ERROR_OTHER &&
+	    result.provider_content_accepted == 1 &&
 	    strcmp(result.error, "provider down") == 0 &&
 	    result.assistant == NULL && f.sent_callbacks == 1 &&
 	    f.accepts == 0 && f.nassistants == 0);
@@ -1730,6 +1761,8 @@ test_provider_and_adapter_errors_remain_distinct(void)
 	rc = turn_run(&adapter, &options, &result);
 	CHECK(rc == 0 && f.bad == 0 && result.status == TURN_STATUS_FAILED &&
 	    result.failure == TURN_FAILURE_ADAPTER && result.data_used == 1 &&
+	    result.provider_error_category == PROVIDER_ERROR_NONE &&
+	    result.provider_content_accepted == 0 &&
 	    strcmp(result.error, "request serialization failed") == 0 &&
 	    result.assistant == NULL && result.stop_reason[0] == '\0');
 	CHECK(f.generation_next == 1 && f.sent_callbacks == 0 &&
@@ -1739,6 +1772,42 @@ test_provider_and_adapter_errors_remain_distinct(void)
 }
 
 static void
+test_context_overflow_projects_to_turn_result(void)
+{
+	struct fixture		 f;
+	struct turn_adapter	 adapter;
+	struct turn_options	 options;
+	struct turn_result	 result;
+	struct scripted_generation *generation;
+	int			 rc;
+
+	fixture_init(&f, &adapter, "q");
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_context_overflow(generation, "request exceeds context window");
+	options_init(&options, TURN_POLICY_LEAD, 1, 2);
+	rc = turn_run(&adapter, &options, &result);
+	CHECK(rc == 0 && f.bad == 0 && result.status == TURN_STATUS_FAILED &&
+	    result.failure == TURN_FAILURE_PROVIDER &&
+	    result.provider_error_category == PROVIDER_ERROR_CONTEXT_OVERFLOW &&
+	    result.provider_content_accepted == 0 && result.data_used == 1 &&
+	    strcmp(result.error, "request exceeds context window") == 0);
+	msglist_free(&f.accepted);
+
+	fixture_init(&f, &adapter, "q");
+	generation = fixture_generation(&f, TURN_TOOLS_ENABLED);
+	script_text(generation, "partial");
+	script_context_overflow(generation, "late overflow");
+	options_init(&options, TURN_POLICY_LEAD, 1, 2);
+	rc = turn_run(&adapter, &options, &result);
+	CHECK(rc == 0 && f.bad == 0 && result.status == TURN_STATUS_FAILED &&
+	    result.failure == TURN_FAILURE_PROVIDER &&
+	    result.provider_error_category == PROVIDER_ERROR_CONTEXT_OVERFLOW &&
+	    result.provider_content_accepted == 1 && result.data_used == 8 &&
+	    strcmp(result.error, "late overflow") == 0);
+	msglist_free(&f.accepted);
+}
+
+static void
 test_out_of_order_results_are_accepted_in_call_order(void)
 {
 	struct fixture	 f;
@@ -1975,6 +2044,7 @@ main(void)
 	test_tools_disabled_call_is_a_conclusion_policy_failure();
 	test_fixed_conclusion_guidance_is_outside_hostile_data_bound();
 	test_provider_and_adapter_errors_remain_distinct();
+	test_context_overflow_projects_to_turn_result();
 	test_out_of_order_results_are_accepted_in_call_order();
 	test_incomplete_or_inconsistent_result_batches_are_rejected();
 	test_turn_data_and_usage_are_accumulated_once();
blob - 02a55dc2107eed0db4319e9bf5a3392133b70222
blob + a9a8f1675970a3f21c588b1745af99de9cf0f35d
--- src/common/anthropic_stream.c
+++ src/common/anthropic_stream.c
@@ -194,6 +194,7 @@ handle_event(struct anthropic_stream *s, const char *t
 
 			memset(&ev, 0, sizeof(ev));
 			ev.kind = ASTREAM_ERROR;
+			ev.error_category = PROVIDER_ERROR_OTHER;
 			if ((eo = json_obj_get(&j, root, "error")) != -1) {
 				(void)obj_ident(&j, eo, "type", &etype);
 				(void)obj_ident(&j, eo, "message", &emsg);
blob - beec2eba89ea957330a8f57292790d96aad1f3ff
blob + 657b425de6e4b62054e4eba65a9a4652f22c0c16
--- src/common/generation.c
+++ src/common/generation.c
@@ -42,6 +42,7 @@ struct generation {
 	struct msg		*assistant;
 	enum generation_outcome outcome;
 	enum generation_invalid_cause invalid_cause;
+	enum provider_error_category provider_error_category;
 	struct generation_span	 synthesized_text;
 	char			 reason[GENERATION_ERROR_MAX];
 	char			 error[GENERATION_ERROR_MAX];
@@ -50,6 +51,7 @@ struct generation {
 	int			 protocol;
 	int			 taken;
 	int			 synthesized;
+	int			 provider_content_accepted;
 };
 
 static void
@@ -201,7 +203,8 @@ generation_feed(struct generation *g, enum generation_
 		return (GENERATION_PROTOCOL);
 	if (len > 0 && data == NULL)
 		return (generation_protocol(g, "missing Generation payload"));
-	if (g->drain && kind != GENERATION_DONE && kind != GENERATION_ERROR)
+	if (g->drain && kind != GENERATION_DONE && kind != GENERATION_ERROR &&
+	    kind != GENERATION_CONTEXT_OVERFLOW)
 		return (generation_drain_event(ev));
 
 	switch (kind) {
@@ -216,6 +219,8 @@ generation_feed(struct generation *g, enum generation_
 			return (generation_drain_event(ev));
 		if (len > 0)
 			buf_add(&g->text, data, len);
+		if (len > 0)
+			g->provider_content_accepted = 1;
 		return (GENERATION_MORE);
 	case GENERATION_TOOL_BEGIN:
 		if (len < sizeof(index) + 3)
@@ -257,6 +262,7 @@ generation_feed(struct generation *g, enum generation_
 		memcpy(call->name, p + 1, namelen);
 		call->name[namelen] = '\0';
 		buf_init(&call->input);
+		g->provider_content_accepted = 1;
 		return (GENERATION_MORE);
 	case GENERATION_TOOL_INPUT:
 		if (len < sizeof(index))
@@ -278,6 +284,8 @@ generation_feed(struct generation *g, enum generation_
 		if (fragmentlen > 0)
 			buf_add(&call->input,
 			    (const u_char *)data + sizeof(index), fragmentlen);
+		if (fragmentlen > 0)
+			g->provider_content_accepted = 1;
 		return (GENERATION_MORE);
 	case GENERATION_USAGE: {
 		struct generation_usage u;
@@ -352,6 +360,7 @@ generation_feed(struct generation *g, enum generation_
 		generation_build_assistant(g);
 		return (GENERATION_TERMINAL);
 	case GENERATION_ERROR:
+	case GENERATION_CONTEXT_OVERFLOW:
 		if (len == 0 || len >= GENERATION_ERROR_MAX ||
 		    memchr(data, '\0', len) != NULL)
 			return (generation_protocol(g,
@@ -366,6 +375,8 @@ generation_feed(struct generation *g, enum generation_
 		memcpy(g->reason, data, len);
 		g->reason[len] = '\0';
 		g->outcome = GENERATION_PROVIDER_ERROR;
+		g->provider_error_category = kind == GENERATION_CONTEXT_OVERFLOW ?
+		    PROVIDER_ERROR_CONTEXT_OVERFLOW : PROVIDER_ERROR_OTHER;
 		generation_set_error(g, g->reason);
 		return (GENERATION_TERMINAL);
 	default:
@@ -386,6 +397,8 @@ generation_take(struct generation *g, struct generatio
 	memset(r, 0, sizeof(*r));
 	r->outcome = g->outcome;
 	r->invalid_cause = g->invalid_cause;
+	r->provider_error_category = g->provider_error_category;
+	r->provider_content_accepted = g->provider_content_accepted;
 	r->assistant = g->assistant;
 	r->usage = g->usage;
 	r->data_used = g->data_used;
blob - 769cff33015930a31fafa58fb25c6f572f327144
blob + 1286e1092a85361b6236930dcfbbad7c81b4547d
--- src/common/generation.h
+++ src/common/generation.h
@@ -21,6 +21,8 @@
 
 #include <stdint.h>
 
+#include "provider_error.h"
+
 struct msg;
 struct generation;
 
@@ -36,9 +38,9 @@ struct generation;
 
 /*
  * Canonical payload kinds after a Provider codec has decoded the wire.
- * TEXT, RETRY, DONE, and ERROR carry their bytes directly.  TOOL_BEGIN is
- * `int index`, then `id\0name` (name is length-carried).  TOOL_INPUT is
- * `int index`, then one argument fragment.  USAGE is exactly one
+ * TEXT, RETRY, DONE, ERROR, and CONTEXT_OVERFLOW carry their bytes directly.
+ * TOOL_BEGIN is `int index`, then `id\0name` (name is length-carried).
+ * TOOL_INPUT is `int index`, then one argument fragment.  USAGE is exactly one
  * struct generation_usage.  The native in-process representation is
  * deliberate: an Adapter, not this Module, owns any transport encoding.
  * Tool indices and ids are each unique within one Generation.
@@ -50,14 +52,16 @@ enum generation_kind {
 	GENERATION_USAGE,
 	GENERATION_RETRY,
 	GENERATION_DONE,
-	GENERATION_ERROR
+	GENERATION_ERROR,
+	GENERATION_CONTEXT_OVERFLOW
 };
 
 /*
  * One feed either extends the Generation, ends it, or enters drain mode.
  * PROTOCOL makes the Generation unusable.  DRAIN latches a local bound;
- * callers continue feeding until the first valid DONE or ERROR, whose
- * status is TERMINAL.  No event is accepted after that terminal.
+ * callers continue feeding until the first valid DONE, ERROR, or
+ * CONTEXT_OVERFLOW, whose status is TERMINAL.  No event is accepted after
+ * that terminal.
  */
 enum generation_feed_status {
 	GENERATION_PROTOCOL = -1,
@@ -115,7 +119,9 @@ struct generation_event {
  * generation_take() transfers assistant to the caller.  reason remains
  * borrowed from the Generation and is valid until generation_free().
  * invalid_cause is non-NONE exactly when outcome is INVALID; an invalid
- * result has no assistant.  synthesized_text is non-empty only for the
+ * result has no assistant.  provider_error_category is non-NONE only for a
+ * Provider error; provider_content_accepted reports text/tool content accepted
+ * before any outcome.  synthesized_text is non-empty only for the
  * placeholder created by an otherwise-empty LENGTH outcome.  It aliases
  * assistant storage and remains valid until that assistant is freed; its
  * bytes are already included in data_used.
@@ -123,6 +129,8 @@ struct generation_event {
 struct generation_result {
 	enum generation_outcome outcome;
 	enum generation_invalid_cause invalid_cause;
+	enum provider_error_category provider_error_category;
+	int			 provider_content_accepted;
 	struct msg		*assistant;
 	struct generation_usage usage;
 	size_t			 data_used;
blob - 668ad1f4bed9525859a8b9e5f17f69ceae7eb77c
blob + e8480670c7decd387141a71a14cc8db6f4b56910
--- src/common/openai.h
+++ src/common/openai.h
@@ -48,7 +48,8 @@ int			 openai_stream_feed(struct openai_stream *,
 			    const void *, size_t);
 const char		*openai_stream_error(const struct openai_stream *);
 
-/* provider type/message out of a non-2xx HTTP error body */
-int	 openai_error_body(const void *, size_t, char *, size_t);
+/* provider type/message and bounded category from a non-2xx HTTP error */
+int	 openai_error_body(const void *, size_t, int, char *, size_t,
+	    enum provider_error_category *);
 
 #endif /* OPENAI_H */
blob - 8caeb21e0de0bab5898e68ef1ee22c71a3b47d6e
blob + b302cf0dda281f2c4069498406b0bdd470b64406
--- src/common/openai_stream.c
+++ src/common/openai_stream.c
@@ -67,6 +67,18 @@ struct openai_stream {
 
 static int	json_is_null_value(const struct json *, int);
 
+static const char openai_context_code[] = "context_length_exceeded";
+
+static enum provider_error_category
+openai_error_category(int status, const char *code)
+{
+	/* status zero denotes an in-band error on a successful SSE response. */
+	if (code != NULL && strcmp(code, openai_context_code) == 0 &&
+	    (status == 0 || status == 400))
+		return (PROVIDER_ERROR_CONTEXT_OVERFLOW);
+	return (PROVIDER_ERROR_OTHER);
+}
+
 static void
 stream_fail(struct openai_stream *s, const char *msg)
 {
@@ -365,9 +377,9 @@ static void
 handle_error(struct openai_stream *s, const struct json *j, int eo)
 {
 	struct astream_event	 ev;
-	char			*type = NULL, *message = NULL;
-	size_t			 typelen = 0, messagelen = 0;
-	int			 rt, rm;
+	char			*type = NULL, *message = NULL, *code = NULL;
+	size_t			 typelen = 0, messagelen = 0, codelen = 0;
+	int			 rt, rm, rc;
 
 	if (!json_is_object(j, eo)) {
 		stream_fail(s, "bad provider error object");
@@ -375,9 +387,15 @@ handle_error(struct openai_stream *s, const struct jso
 	}
 	rt = obj_ident(j, eo, "type", &type, &typelen);
 	rm = obj_ident(j, eo, "message", &message, &messagelen);
+	rc = obj_ident(j, eo, "code", &code, &codelen);
+	if (rc < 0 || codelen >= 128) {
+		free(code);
+		code = NULL;
+	}
 	if (rt < 0 || rm < 0 || typelen >= 128 || messagelen >= 1024) {
 		free(type);
 		free(message);
+		free(code);
 		stream_fail(s, "NUL in provider error");
 		return;
 	}
@@ -385,9 +403,11 @@ handle_error(struct openai_stream *s, const struct jso
 	ev.kind = ASTREAM_ERROR;
 	ev.err_type = type != NULL ? type : "error";
 	ev.err_message = message != NULL ? message : "";
+	ev.error_category = openai_error_category(0, code);
 	s->cb(&ev, s->arg);
 	free(type);
 	free(message);
+	free(code);
 	stream_fail(s, "provider error event");
 }
 
@@ -535,12 +555,18 @@ openai_stream_feed(struct openai_stream *s, const void
 }
 
 int
-openai_error_body(const void *data, size_t len, char *out, size_t outsz)
+openai_error_body(const void *data, size_t len, int status, char *out,
+    size_t outsz, enum provider_error_category *category)
 {
 	struct json	 j;
-	char		*type = NULL, *message = NULL;
-	int		 root, eo, rt, rm;
+	char		*type = NULL, *message = NULL, *code = NULL;
+	size_t		 codelen = 0;
+	int		 root, eo, rt, rm, rc;
 
+	if (category == NULL)
+		return (-1);
+	*category = PROVIDER_ERROR_OTHER;
+
 	if (json_parse(&j, data, len, 0) != 0)
 		return (-1);
 	root = json_root(&j);
@@ -552,17 +578,25 @@ openai_error_body(const void *data, size_t len, char *
 	}
 	rt = obj_ident(&j, eo, "type", &type, NULL);
 	rm = obj_ident(&j, eo, "message", &message, NULL);
+	rc = obj_ident(&j, eo, "code", &code, &codelen);
 	json_done(&j);
+	if (rc < 0 || codelen >= 128) {
+		free(code);
+		code = NULL;
+	}
 	if (rt < 0 || rm < 0 || (type == NULL && message == NULL)) {
 		free(type);
 		free(message);
+		free(code);
 		return (-1);
 	}
+	*category = openai_error_category(status, code);
 	(void)snprintf(out, outsz, "%s%s%s",
 	    type != NULL ? type : "error",
 	    message != NULL ? ": " : "",
 	    message != NULL ? message : "");
 	free(type);
 	free(message);
+	free(code);
 	return (0);
 }
blob - 4b18cf7bfde90367ee9d33c05d9b6007a95e9262
blob + fc88c006923fe1a11ce82ffcb119408d10f00761
--- src/common/provider_stream.h
+++ src/common/provider_stream.h
@@ -21,6 +21,8 @@
 
 #include <stdint.h>
 
+#include "provider_error.h"
+
 /*
  * Provider-neutral events emitted by each wire decoder.  They are the
  * credential custodian's length-carried translation seam: the Coordinator
@@ -57,6 +59,7 @@ struct astream_event {
 
 	const char		*err_type;	/* ERROR */
 	const char		*err_message;
+	enum provider_error_category error_category;
 };
 
 typedef void (*astream_cb)(const struct astream_event *, void *);
blob - /dev/null
blob + a0fcc634ea26b7a54dd71c3259ea621b6971e07b (mode 644)
--- /dev/null
+++ src/common/provider_error.h
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2026 Isaac <isaac@itm.works>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef PROVIDER_ERROR_H
+#define PROVIDER_ERROR_H
+
+/*
+ * A bounded, provider-neutral failure classification.  OTHER retains the
+ * existing terminal Provider-error behavior; CONTEXT_OVERFLOW is reserved
+ * for an exact structured signal recognized by a Provider Adapter.
+ */
+enum provider_error_category {
+	PROVIDER_ERROR_NONE = 0,
+	PROVIDER_ERROR_OTHER,
+	PROVIDER_ERROR_CONTEXT_OVERFLOW,
+	PROVIDER_ERROR_MAX
+};
+
+#endif /* PROVIDER_ERROR_H */
blob - 18f0d987fe56d92ab02525c5e9c8bf451f0f7272
blob + eb43c47a6b721daed18e537eccde0b3a9d378c29
--- src/common/turn_mechanics.c
+++ src/common/turn_mechanics.c
@@ -111,6 +111,8 @@ turn_fail(struct turn_state *turn, enum turn_failure f
 	turn->result->status = TURN_STATUS_FAILED;
 	turn->result->failure = failure;
 	turn->result->invalid_cause = invalid_cause;
+	turn->result->provider_error_category = PROVIDER_ERROR_NONE;
+	turn->result->provider_content_accepted = 0;
 	turn->result->assistant = NULL;
 	turn->result->stop_reason[0] = '\0';
 	turn_result_account(turn);
@@ -118,16 +120,31 @@ turn_fail(struct turn_state *turn, enum turn_failure f
 }
 
 static void
+turn_fail_provider(struct turn_state *turn,
+    const struct generation_result *result)
+{
+	turn_fail(turn, TURN_FAILURE_PROVIDER, GENERATION_INVALID_NONE,
+	    result->reason);
+	turn->result->provider_error_category = result->provider_error_category;
+	turn->result->provider_content_accepted =
+	    result->provider_content_accepted;
+}
+
+static void
 turn_interrupt(struct turn_state *turn)
 {
 	turn->result->status = TURN_STATUS_INTERRUPTED;
 	turn->result->failure = TURN_FAILURE_NONE;
 	turn->result->invalid_cause = GENERATION_INVALID_NONE;
+	turn->result->provider_error_category = PROVIDER_ERROR_NONE;
+	turn->result->provider_content_accepted = 0;
 	turn->result->assistant = NULL;
 	turn->result->stop_reason[0] = '\0';
 	turn->result->error[0] = '\0';
 	turn->result->conclusion_failure = TURN_FAILURE_NONE;
 	turn->result->conclusion_invalid_cause = GENERATION_INVALID_NONE;
+	turn->result->conclusion_provider_error_category = PROVIDER_ERROR_NONE;
+	turn->result->conclusion_provider_content_accepted = 0;
 	turn->result->conclusion_error[0] = '\0';
 	turn_result_account(turn);
 }
@@ -412,8 +429,7 @@ turn_generate(struct turn_state *turn, enum turn_tools
 		return (-1);
 	}
 	if (step->result.outcome == GENERATION_PROVIDER_ERROR) {
-		turn_fail(turn, TURN_FAILURE_PROVIDER, GENERATION_INVALID_NONE,
-		    step->result.reason);
+		turn_fail_provider(turn, &step->result);
 		return (-1);
 	}
 	if (step->result.outcome == GENERATION_INVALID) {
@@ -668,11 +684,17 @@ turn_conclude(struct turn_state *turn)
 failed:
 	turn->result->conclusion_failure = turn->result->failure;
 	turn->result->conclusion_invalid_cause = turn->result->invalid_cause;
+	turn->result->conclusion_provider_error_category =
+	    turn->result->provider_error_category;
+	turn->result->conclusion_provider_content_accepted =
+	    turn->result->provider_content_accepted;
 	turn_strerror(turn->result->conclusion_error,
 	    sizeof(turn->result->conclusion_error), turn->result->error);
 	turn->result->status = TURN_STATUS_CONCLUDED;
 	turn->result->failure = TURN_FAILURE_NONE;
 	turn->result->invalid_cause = GENERATION_INVALID_NONE;
+	turn->result->provider_error_category = PROVIDER_ERROR_NONE;
+	turn->result->provider_content_accepted = 0;
 	turn->result->assistant = fallback;
 	turn_strerror(turn->result->stop_reason,
 	    sizeof(turn->result->stop_reason), stop_reason);
blob - 78385af229953d731d1d02cccf6ca08726f3513f
blob + 355785fb1a00449bdf44276859d9a7f026b35e22
--- src/common/turn_mechanics.h
+++ src/common/turn_mechanics.h
@@ -130,8 +130,12 @@ struct turn_result {
 	enum turn_status		 status;
 	enum turn_failure	 failure;
 	enum generation_invalid_cause invalid_cause;
+	enum provider_error_category provider_error_category;
+	int			 provider_content_accepted;
 	enum turn_failure	 conclusion_failure;
 	enum generation_invalid_cause conclusion_invalid_cause;
+	enum provider_error_category conclusion_provider_error_category;
+	int			 conclusion_provider_content_accepted;
 	/* Completed Generations plus nested usage reported by execute(). */
 	struct generation_usage usage;
 	size_t			 data_used;
@@ -173,7 +177,8 @@ struct turn_adapter {
  * options.data_used plus hostile bytes accepted by completed collectors, even
  * when cancellation prevents Projection acceptance.  A forced Subagent
  * conclusion remains CONCLUDED when its single final Generation fails;
- * conclusion_* describes that fallback.
+ * conclusion_* describes that fallback.  Provider category/content fields
+ * project the terminal Generation that caused the matching Provider failure.
  */
 int	turn_run(const struct turn_adapter *, const struct turn_options *,
 	    struct turn_result *);
blob - 1bb802445df759a57bc8d38fc2c0dfa2fa850466
blob + e9856197ed488e1ee142c8e93a296cdf7922f96f
--- src/fugu/coord.c
+++ src/fugu/coord.c
@@ -1290,6 +1290,9 @@ stream_event(struct coord *c, struct imsg *imsg)
 	case FUGU_IMSG_A_ERROR:
 		kind = GENERATION_ERROR;
 		break;
+	case FUGU_IMSG_A_CONTEXT_OVERFLOW:
+		kind = GENERATION_CONTEXT_OVERFLOW;
+		break;
 	default:
 		fatalx("unexpected lead provider imsg %u", type);
 	}
@@ -1314,7 +1317,8 @@ stream_event(struct coord *c, struct imsg *imsg)
 	 * its standalone fail-closed Generation behavior.
 	 */
 	if (c->turn_generation_io != NULL && c->generation_protocol) {
-		if (kind == GENERATION_DONE || kind == GENERATION_ERROR) {
+		if (kind == GENERATION_DONE || kind == GENERATION_ERROR ||
+		    kind == GENERATION_CONTEXT_OVERFLOW) {
 			c->generation_terminal = 1;
 			c->wait = WAIT_NONE;
 			event_loopexit(NULL);
@@ -1332,7 +1336,8 @@ stream_event(struct coord *c, struct imsg *imsg)
 
 		if (c->turn_generation_io != NULL) {
 			c->generation_protocol = 1;
-			if (kind == GENERATION_DONE || kind == GENERATION_ERROR) {
+			if (kind == GENERATION_DONE || kind == GENERATION_ERROR ||
+			    kind == GENERATION_CONTEXT_OVERFLOW) {
 				c->generation_terminal = 1;
 				c->wait = WAIT_NONE;
 				event_loopexit(NULL);
@@ -1906,6 +1911,7 @@ allowed_from(enum fugu_role role, uint32_t type)
 	case FUGU_IMSG_A_RETRY:
 	case FUGU_IMSG_A_DONE:
 	case FUGU_IMSG_A_ERROR:
+	case FUGU_IMSG_A_CONTEXT_OVERFLOW:
 	case FUGU_IMSG_MODEL:
 	case FUGU_IMSG_MODELS_END:
 		return (role == ROLE_API);
@@ -2012,7 +2018,8 @@ agent_provider_event(struct subagent *a, struct imsg *
 	len = payload.len;
 	if (!a->done)
 		imsgev_send(&a->iev, type, 0, p, len);
-	if (type == FUGU_IMSG_A_DONE || type == FUGU_IMSG_A_ERROR)
+	if (type == FUGU_IMSG_A_DONE || type == FUGU_IMSG_A_ERROR ||
+	    type == FUGU_IMSG_A_CONTEXT_OVERFLOW)
 		a->api_inflight = 0;
 	if (a->coord->wait == WAIT_AGENTS && agents_settled(a->coord))
 		event_loopexit(NULL);
@@ -2160,6 +2167,7 @@ coord_dispatch(int fd, short event, void *arg)
 		case FUGU_IMSG_A_RETRY:
 		case FUGU_IMSG_A_DONE:
 		case FUGU_IMSG_A_ERROR:
+		case FUGU_IMSG_A_CONTEXT_OVERFLOW:
 			if (imsg_get_id(&imsg) == coord->lead_api_id)
 				stream_event(coord, &imsg);
 			else {
@@ -2167,7 +2175,8 @@ coord_dispatch(int fd, short event, void *arg)
 				int terminal;
 
 				terminal = type == FUGU_IMSG_A_DONE ||
-				    type == FUGU_IMSG_A_ERROR;
+				    type == FUGU_IMSG_A_ERROR ||
+				    type == FUGU_IMSG_A_CONTEXT_OVERFLOW;
 				if ((a = agent_by_api_id(coord,
 				    imsg_get_id(&imsg))) != NULL)
 					agent_provider_event(a, &imsg);
blob - 7b8578e1ec86394caa443f5a5995fcec73cd9bb9
blob + 17023b82816ee90f02871ec7e42960398f530609
--- src/fugu/proto.h
+++ src/fugu/proto.h
@@ -165,6 +165,7 @@ enum fugu_imsg {
 	FUGU_IMSG_A_RETRY,	/* transient failure, retrying: notice string */
 	FUGU_IMSG_A_DONE,	/* stop_reason string; turn complete */
 	FUGU_IMSG_A_ERROR,	/* provider/transport error string */
+	FUGU_IMSG_A_CONTEXT_OVERFLOW, /* exact typed Provider overflow */
 
 	/* a tool call: coordinator -> fugu-tool, then results stream back.
 	 * The input JSON and the result are each chunked (16 KiB imsg
blob - bf01efcf15f2cd29b2d1068e0e201b2362451e6a
blob + bd840851d4e73fb8dd0e7e17a989c435b707833d
--- src/fugu-api/main.c
+++ src/fugu-api/main.c
@@ -756,6 +756,8 @@ usage_add(struct a_usage *dst, const struct a_usage *s
 	return (0);
 }
 
+static void relay_error_category(struct api_request *, const char *,
+    enum provider_error_category);
 static void relay_error(struct api_request *, const char *);
 
 static void
@@ -916,17 +918,22 @@ on_event(const struct astream_event *ev, void *arg)
 		{
 			char	 reason[FUGU_ERROR_MAX];
 			const char *parts[3];
+			enum provider_error_category category;
 
+			/* Anthropic's shared invalid-request shape is not specific. */
+			category = a->provs[r->slot].type == PROVIDER_OPENAI &&
+			    ev->error_category == PROVIDER_ERROR_CONTEXT_OVERFLOW ?
+			    PROVIDER_ERROR_CONTEXT_OVERFLOW : PROVIDER_ERROR_OTHER;
 			parts[0] = ev->err_type != NULL ? ev->err_type : "error";
 			parts[1] = ev->err_message != NULL &&
 			    ev->err_message[0] != '\0' ? ": " : "";
 			parts[2] = ev->err_message != NULL ? ev->err_message : "";
 			if (contains_secret_parts(a, parts, 3))
-				relay_error(r, "\n");
+				relay_error_category(r, "\n", category);
 			else {
 				(void)snprintf(reason, sizeof(reason), "%s%s%s",
 				    parts[0], parts[1], parts[2]);
-				relay_error(r, reason);
+				relay_error_category(r, reason, category);
 			}
 		}
 		break;
@@ -934,7 +941,8 @@ on_event(const struct astream_event *ev, void *arg)
 }
 
 static void
-relay_error(struct api_request *r, const char *msg)
+relay_error_category(struct api_request *r, const char *msg,
+    enum provider_error_category category)
 {
 	struct api	*a = r->api;
 	/* CR/LF are forbidden in configured credentials.  A single newline is
@@ -950,11 +958,19 @@ relay_error(struct api_request *r, const char *msg)
 	} else if (len >= FUGU_ERROR_MAX)
 		len = FUGU_ERROR_MAX - 1;
 	secret_streams_clear(r);
-	relay(r, FUGU_IMSG_A_ERROR, msg, len);
+	/* No unrecognized enum value is allowed onto the typed wire path. */
+	relay(r, category == PROVIDER_ERROR_CONTEXT_OVERFLOW ?
+	    FUGU_IMSG_A_CONTEXT_OVERFLOW : FUGU_IMSG_A_ERROR, msg, len);
 	r->terminal = 1;
 }
 
+static void
+relay_error(struct api_request *r, const char *msg)
+{
+	relay_error_category(r, msg, PROVIDER_ERROR_OTHER);
+}
 
+
 /*
  * Provider requests are independent libevent state machines.  The API role
  * remains the sole credential custodian, but a slow subagent connection no
@@ -1240,6 +1256,15 @@ request_fail(struct api_request *r, const char *reason
 	request_free(r);
 }
 
+static void
+request_fail_category(struct api_request *r, const char *reason,
+    enum provider_error_category category)
+{
+	if (!r->terminal)
+		relay_error_category(r, reason, category);
+	request_free(r);
+}
+
 /* Retryable failures are only connect/TLS, HTTP 429, and 5xx before a
  * content delta.  Each request owns its timer, so retries remain concurrent. */
 static void
@@ -1286,13 +1311,14 @@ request_error_done(struct api_request *r)
 	struct prov *v = &r->api->provs[r->slot];
 	char *raw, *reason;
 	size_t rawsz = API_ERRBODY_MAX + 32;
+	enum provider_error_category category = PROVIDER_ERROR_OTHER;
 	int retryable = r->retryable;
 	int retry_after = r->retry_after;
 
 	raw = xcalloc(rawsz, 1);
 	if ((v->type == PROVIDER_OPENAI ?
-	    openai_error_body(r->errbody.data, r->errbody.len, raw,
-	    rawsz) :
+	    openai_error_body(r->errbody.data, r->errbody.len, r->http.status,
+	    raw, rawsz, &category) :
 	    anthropic_error_body(r->errbody.data, r->errbody.len, raw,
 	    rawsz)) == -1)
 		(void)snprintf(raw, rawsz, "HTTP %d from provider",
@@ -1305,7 +1331,7 @@ request_error_done(struct api_request *r)
 	if (retryable)
 		request_retry(r, reason, retry_after);
 	else
-		request_fail(r, reason);
+		request_fail_category(r, reason, category);
 	freezero(reason, strlen(reason));
 	freezero(raw, rawsz);
 }
blob - 1c1397340c9773ece4b600e2a764f3320239b078
blob + 6fe07fe93ca07271f5c5411e2c7f1917583330b1
--- src/fugu-tool/agent.c
+++ src/fugu-tool/agent.c
@@ -363,6 +363,8 @@ agent_generation_kind(uint32_t type)
 		return (GENERATION_DONE);
 	case FUGU_IMSG_A_ERROR:
 		return (GENERATION_ERROR);
+	case FUGU_IMSG_A_CONTEXT_OVERFLOW:
+		return (GENERATION_CONTEXT_OVERFLOW);
 	default:
 		fatalx("subagent unexpected imsg %u", type);
 	}
@@ -535,7 +537,8 @@ agent_generate(void *arg, const struct turn_generation
 		kind = agent_generation_kind(type);
 		if (protocol) {
 			imsg_free(&imsg);
-			if (kind == GENERATION_DONE || kind == GENERATION_ERROR)
+			if (kind == GENERATION_DONE || kind == GENERATION_ERROR ||
+			    kind == GENERATION_CONTEXT_OVERFLOW)
 				return (TURN_IO_ERROR);
 			continue;
 		}
@@ -556,7 +559,8 @@ agent_generate(void *arg, const struct turn_generation
 		if (status == GENERATION_PROTOCOL) {
 			protocol = 1;
 			imsg_free(&imsg);
-			if (kind == GENERATION_DONE || kind == GENERATION_ERROR)
+			if (kind == GENERATION_DONE || kind == GENERATION_ERROR ||
+			    kind == GENERATION_CONTEXT_OVERFLOW)
 				return (TURN_IO_ERROR);
 			continue;
 		}