Skip to content
58 changes: 40 additions & 18 deletions packages/ai/src/api/anthropic-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -906,6 +906,13 @@ function isNativeToolSearchResultBlock(block: unknown): block is Record<string,
);
}

/** Numeric HTTP status carried by an SDK error (Anthropic APIError.status), if any. */
function httpStatusOfError(error: unknown): number | undefined {
if (!isRecord(error)) return undefined;
const status = error.status;
return typeof status === "number" && Number.isInteger(status) && status >= 100 && status < 600 ? status : undefined;
}

function demoteUnavailableToolReferences(params: MessageCreateParamsStreaming): MessageCreateParamsStreaming {
const messages = params.messages;
if (!Array.isArray(messages) || messages.length === 0) return params;
Expand Down Expand Up @@ -1482,25 +1489,40 @@ export const stream: StreamFunction<"anthropic-messages", AnthropicOptions> = (
throw error;
}
};
const { params: sentParams, response } = await retryProviderRequest(
async () => {
try {
return await createRequest();
} catch (error) {
if (unsignedThinkingReplay !== "text" && isInvalidUnsignedThinkingSignatureError(error)) {
unsignedThinkingReplay = "text";
if (fallbackKey) unsignedThinkingTextReplayFallbacks.add(fallbackKey);
return createRequest();
let requestOutcome: { params: MessageCreateParamsStreaming; response: Response };
try {
requestOutcome = await retryProviderRequest(
async () => {
try {
return await createRequest();
} catch (error) {
if (unsignedThinkingReplay !== "text" && isInvalidUnsignedThinkingSignatureError(error)) {
unsignedThinkingReplay = "text";
if (fallbackKey) unsignedThinkingTextReplayFallbacks.add(fallbackKey);
return createRequest();
}
throw error;
}
throw error;
}
},
{
maxRetries: options?.maxRetries,
maxRetryDelayMs: options?.maxRetryDelayMs,
signal: requestSignal,
},
);
},
{
maxRetries: options?.maxRetries,
maxRetryDelayMs: options?.maxRetryDelayMs,
signal: requestSignal,
},
);
} catch (error) {
// The SDK rejects HTTP failures instead of returning a Response, so a
// rejected request never reached onResponse. Deliver the numeric status
// once, after every internal retry, before the caller handles the error;
// errors without a status (network, aborts) report nothing rather than
// a fabricated code.
const status = httpStatusOfError(error);
if (status !== undefined) {
await options?.onResponse?.({ status, headers: {} }, model);
}
throw error;
}
const { params: sentParams, response } = requestOutcome;
await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
stream.push({ type: "start", partial: output });

Expand Down
18 changes: 18 additions & 0 deletions packages/ai/src/changes.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,22 @@

## 2026-09-08 - Deliver provider HTTP status on rejected Anthropic requests (senpi #1481)

### What changed

- `packages/ai/src/api/anthropic-messages.ts`: when the complete `retryProviderRequest` operation finally rejects, a numeric HTTP status carried by the SDK error (`APIError.status`) is delivered once through `options.onResponse` (`httpStatusOfError`) before the error is rethrown. Success-path delivery is unchanged; errors without a status (network, aborts) report nothing rather than a fabricated code.
- `packages/ai/test/anthropic-on-response-error.test.ts`: a rejecting fake client proves status 400 and 500 reach `onResponse` exactly once and that a status-less error produces no callback.

### Why

- The SDK turns HTTP failures into rejections instead of a Response, so the success-only `onResponse` never fired for them. The native tool-search adapter's permanent 400 fallback (`noteResponseStatus`, senpi #1481) was unreachable on the live error path, and any other `after_provider_response` extension was blind to error statuses.

### Why an extension could not handle it

- The status exists only inside the provider's own request error object; an extension observing the payload hook or the assistant error message cannot recover the HTTP code.

### Expected merge conflict zones

- MEDIUM: the request construction block in `packages/ai/src/api/anthropic-messages.ts` (upstream has no error-path callback); LOW: the new test file (fork-only).
## 2026-09-08 - Anthropic tool references resolve against the request's own tools (senpi native tool-search 400)

### What changed
Expand Down
89 changes: 89 additions & 0 deletions packages/ai/test/anthropic-on-response-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type Anthropic from "@anthropic-ai/sdk";
import { describe, expect, it, vi } from "vitest";
import { getModel } from "../src/compat.ts";
import { streamAnthropic } from "../src/providers/anthropic.ts";
import type { Context } from "../src/types.ts";

/**
* The Anthropic SDK turns HTTP failures into a rejected `APIError`, so a 400
* never produced a Response object and `onResponse` was never called. The
* native tool-search adapter (and every `after_provider_response` extension)
* needs the status anyway: its permanent 400 fallback is keyed on it. Deliver
* the numeric status on the rejection path once, after internal retries, and
* never fabricate one for errors that carry no status.
*/

function createRejectingAnthropicClient(error: unknown): Anthropic {
return {
beta: {
messages: {
create: () => ({
asResponse: async () => {
throw error;
},
}),
},
},
} as unknown as Anthropic;
}

function anthropicApiError(status: number, message: string): Error & { status: number } {
const error = new Error(
`${status} {"type":"error","error":{"type":"invalid_request_error","message":"${message}"}}`,
) as Error & {
status: number;
};
error.status = status;
return error;
}

function streamWith(
error: unknown,
onResponse: (response: { status: number; headers: unknown }, model: unknown) => void,
) {
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
tools: [],
};
return streamAnthropic(getModel("anthropic", "claude-haiku-4-5"), context, {
apiKey: "fake-key",
client: createRejectingAnthropicClient(error),
onResponse: onResponse as never,
});
}

describe("Anthropic onResponse on rejected requests", () => {
it("reports the HTTP status when the SDK rejects with an APIError", async () => {
const onResponse = vi.fn();
const s = streamWith(
anthropicApiError(400, "Tool reference 'mcp__925c__memory' not found in available tools"),
onResponse,
);

const message = await s.result();
expect(message.stopReason).toBe("error");
expect(String(message.errorMessage)).toContain("400");
expect(onResponse).toHaveBeenCalledTimes(1);
expect(onResponse).toHaveBeenCalledWith(expect.objectContaining({ status: 400 }), expect.anything());
});

it("reports other numeric statuses too", async () => {
const onResponse = vi.fn();
const s = streamWith(anthropicApiError(500, "internal error"), onResponse);

const message = await s.result();
expect(message.stopReason).toBe("error");
expect(onResponse).toHaveBeenCalledTimes(1);
expect(onResponse).toHaveBeenCalledWith(expect.objectContaining({ status: 500 }), expect.anything());
});

it("does not report a fabricated status for errors that carry none", async () => {
const onResponse = vi.fn();
const s = streamWith(new Error("socket hangup"), onResponse);

const message = await s.result();
expect(message.stopReason).toBe("error");
expect(String(message.errorMessage)).toContain("socket hangup");
expect(onResponse).not.toHaveBeenCalled();
});
});
4 changes: 4 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@

### Fixed

- A rejected Anthropic request now reports its HTTP status through the provider response hook: previously the Anthropic SDK's rejection path never reached `onResponse`/`after_provider_response`, so the native tool-search adapter's permanent 400 fallback was dead code on the live error path (senpi #1481). Errors without a numeric status (network failures, aborts) report nothing rather than a fabricated code.

- A native tool-search 400 no longer demotes the session to a weaker model: the turn is retried once in place on the same model with native injection already disabled for the session, and only a second rejection consults the fallback chain (senpi #1482).

- `/gpt-account add` now shows the OpenAI Codex login-method chooser as a real selector (`Browser login (default)` / `Device code login (headless)`) instead of an empty text input that failed with `Unknown OpenAI Codex login method:` on Enter. The device-code flow prints the user code next to the verification URL, the browser flow opens the browser in the terminal UI and still prints the URL, and the paste-the-code dialog closes by itself once the local callback completes the login. `/claude-account add` shares the same prompt relay ([#1485](https://github.com/code-yeongyu/senpi/issues/1485)).

- Anthropic requests no longer fail with `Tool reference '<name>' not found in available tools` after a native tool search: references that come back under a gateway namespace (`mcp__<id>__<tool>`) are folded onto the request's own tool names before the request is sent, references that no longer resolve are dropped, and a search result left with no references is demoted to text instead of being replayed verbatim. A history tool call whose only justification was such a dangling reference is demoted like any other unavailable call, so one stale native search result can no longer hard-error the model and force a fallback.
Expand Down
66 changes: 44 additions & 22 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7690,6 +7690,14 @@ export class AgentSession {
);
}

private _takeNativeToolSearchInjectionFailure(): string | null {
try {
return getToolSearchService().takeNativeInjectionFailure();
} catch {
return null;
}
}

private _getProviderRetryDelayMs(errorMessage: string): number | undefined {
const markerMs = parseRetryAfterMsMarker(errorMessage);
if (markerMs !== undefined) return markerMs;
Expand Down Expand Up @@ -7822,6 +7830,7 @@ export class AgentSession {
const hardErrorFallback = options.hardErrorFallback === true;
const sameModelRemint = options.sameModelRemint === true;
let switchedFallback = false;
let sameModelNativeRecovery = false;
let is429TierRouted = false;
let hintTierDelayMs: number | undefined;
const tryFallback = async (
Expand Down Expand Up @@ -7855,25 +7864,37 @@ export class AgentSession {
return "not-handled";
}
} else if (hardErrorFallback) {
// A non-retryable provider failure must never replay on the same model.
// Billing-class failures never recover on this account, so the fallback
// switch pins as the session model instead of reverting after the cooldown.
const reason = isBillingErrorMessage(errorMessage) ? "billing" : "hard-error";
switchedFallback = await tryFallback(reason, { errorMessage });
if (!switchedFallback) {
const exhaustedChainKey = this._retryFallback.exhaustedChainKey;
if (exhaustedChainKey) {
this._emit({
type: "retry_fallback_exhausted",
chainKey: exhaustedChainKey,
lastError: errorMessage,
});
// A rejected native tool-search request is recoverable in place: the
// adapter is disabled for the session on the 400, so the SAME model can
// succeed on the immediate next attempt and the fallback chain must not
// demote the user to a weaker model. The pending flag is consumed once,
// so a second rejection takes the ordinary hard-error path below.
const nativeSearchFailure = this._takeNativeToolSearchInjectionFailure();
if (nativeSearchFailure !== null) {
sameModelNativeRecovery = true;
// The recovery starts fresh, mirroring the fallback branch's attempt bookkeeping.
this._retryAttempt = 1;
} else {
// A non-retryable provider failure must never replay on the same model.
// Billing-class failures never recover on this account, so the fallback
// switch pins as the session model instead of reverting after the cooldown.
const reason = isBillingErrorMessage(errorMessage) ? "billing" : "hard-error";
switchedFallback = await tryFallback(reason, { errorMessage });
if (!switchedFallback) {
const exhaustedChainKey = this._retryFallback.exhaustedChainKey;
if (exhaustedChainKey) {
this._emit({
type: "retry_fallback_exhausted",
chainKey: exhaustedChainKey,
lastError: errorMessage,
});
}
this._resolveRetry();
return "not-handled";
}
this._resolveRetry();
return "not-handled";
// The fallback starts fresh; the failed model's transient attempts do not carry over.
this._retryAttempt = 1;
}
// The fallback starts fresh; the failed model's transient attempts do not carry over.
this._retryAttempt = 1;
} else if (isRefusal) {
// Refusals are only retried through a new chain candidate. They never use
// same-model retries or the transient over-budget fallback escape hatch.
Expand Down Expand Up @@ -8169,11 +8190,12 @@ export class AgentSession {
this._retryAttempt,
this._retryRandom(),
);
const delayMs = switchedFallback
? 0
: is429TierRouted
? (hintTierDelayMs ?? providerDelayMs ?? localExponentialMs)
: (nonTierProviderDelayMs ?? localExponentialMs);
const delayMs =
switchedFallback || sameModelNativeRecovery
? 0
: is429TierRouted
? (hintTierDelayMs ?? providerDelayMs ?? localExponentialMs)
: (nonTierProviderDelayMs ?? localExponentialMs);
// Prepare before auto_retry_start so an immediate Esc can cancel the retry sleep.
this._retryAbortController = new AbortController();

Expand Down
19 changes: 19 additions & 0 deletions packages/coding-agent/src/core/changes.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
## Same-model recovery for a native tool-search 400 (2026-09-08)

### What changed

- `packages/coding-agent/src/core/agent-session.ts`: the hard-error fallback branch first consumes the session's pending native tool-search injection failure (`_takeNativeToolSearchInjectionFailure`). When present, it skips `tryFallback()` and runs the shared retry scheduling (zero-delay `auto_retry_start`, failed-message removal, continuation) on the SAME model — the adapter is already disabled for the session, so the next attempt succeeds in place and the user is not demoted to a weaker model. The pending flag is consumed once, so a second rejection takes the ordinary hard-error chain.
- `packages/coding-agent/test/suite/retry-fallback-hard-error.test.ts`: two cases pin the contract — one same-model retry with no `retry_fallback_applied` events, and a normal fallback switch on the second consecutive 400.

### Why

- A native tool-search 400 hard-errored the model and the hard-error branch always switched to the next fallback candidate (`RetryFallbackController` intentionally excludes the current model), demoting the user mid-task even though the same model succeeds once native injection is off (senpi #1482).

### Why an extension could not handle it

- No hook exists at the fallback-decision point; the retry branch is session-owned. The extension can only record that its own request was rejected (the pending flag on the provider-scoped `ToolSearchService`) — consuming it must happen in the session.

### Expected merge conflict zones

- MEDIUM: the `hardErrorFallback` branch and retry-delay computation in `agent-session.ts` (fork-heavy area); LOW: the suite test additions.

## GPT-6 Astra high-reasoning warning parity (2026-09-08)

### What changed
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# Tool Search Builtin Changes

## 2026-09-08 - Wire the native 400 fallback into a session recovery signal (senpi #1481/#1482)

### What changed

- `service.ts`: `ToolSearchService` carries a one-shot pending flag (`noteNativeInjectionFailure` / `takeNativeInjectionFailure`) recording that a native-injected request was rejected.
- `index.ts`: the adapter's `onFallback` now records that reason on the service, so the session's retry branch can recover in place (senpi #1482) instead of falling back blindly.
- `test/tool-search/native-anthropic.test.ts`: a wiring case drives `emitBeforeProviderRequest` (with a supported Anthropic model and an MCP feed) and `after_provider_response` 400, asserting the flag is set once, consumed once, and injection stays off afterwards.

### Why

- `AnthropicNativeToolSearchAdapter` already disables itself permanently on a 400, but nothing told the session WHY the current turn failed; the flag is the provider-scope-scoped channel between the extension and the session's retry branch.

### Expected merge conflict zones

- LOW: the adapter construction site in `index.ts` and the service class; both are fork-owned.


## 2026-09-04 - Gate native tool-search on model support and fix the tool_reference field

### What changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export function createToolSearchExtension(service: ToolSearchService): Extension
return doc?.source === "extension" && !pi.getActiveTools().includes(name);
},
searchToolName: TOOL_SEARCH_TOOL_NAME,
onFallback: (reason) => service.noteNativeInjectionFailure(reason),
});
pi.on("before_provider_request", (event, ctx) =>
nativeAdapter.applyBeforeRequest(event.model ?? ctx.model, event.payload),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@ export class ToolSearchService {
});
}

#nativeInjectionFailure: string | null = null;

/** Record that a native-injected request was rejected; the session consumes it once. */
noteNativeInjectionFailure(reason: string): void {
this.#nativeInjectionFailure = reason;
}

/** Consume the pending native-injection failure, if any (one-shot). */
takeNativeInjectionFailure(): string | null {
const reason = this.#nativeInjectionFailure;
this.#nativeInjectionFailure = null;
return reason;
}

bindRuntime(runtime: RuntimeApi): void {
this.#runtime = runtime;
}
Expand Down
Loading