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: 2 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
- Bundled Claude Code is now 2.1.259 via `@anthropic-ai/claude-agent-sdk` 0.3.259, so `claude-sdk-oauth` sessions on `claude-fable-5-1` no longer fail with the API 400 that required version 2.1.251 or newer ([#1298](https://github.com/code-yeongyu/senpi/issues/1298)).
- Claude SDK OAuth maps malformed or raw-string content entries to text (or an omission placeholder) instead of image blocks with undefined `media_type`/`data`, which made Claude Code abort the next query ([oh-my-openagent#7660](https://github.com/code-yeongyu/oh-my-openagent/issues/7660)).
- A second `claude-sdk-oauth` login now stores the newly issued OAuth tokens instead of a broken slot holding the managed placeholder, and no longer fails with `Provider is not configured: claude-sdk-oauth` when account rotation has selected a single account ([#1279](https://github.com/code-yeongyu/senpi/issues/1279)).
- Claude SDK OAuth `is_error` results now trigger model fallback and multi-account failover instead of being treated as successful results ([#1169](https://github.com/code-yeongyu/senpi/issues/1169)).
- Claude SDK OAuth now surfaces the SDK assistant's API error text and explains version-floor and unknown-model failures instead of reporting `unknown` ([#1298](https://github.com/code-yeongyu/senpi/issues/1298), [oh-my-openagent#7626](https://github.com/code-yeongyu/oh-my-openagent/issues/7626)).
### New Features

### Breaking Changes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { selectAccount } from "./affinity.ts";
import { type AuthenticatedAttemptInput, createAttemptMessages, type RetainableAttempt } from "./auth-attempt.ts";
import { hasRequestOauthToken, mergeRequestAuthEnvironment, stripManagedAuthEnvironment } from "./auth-environment.ts";
import { writeConfigDirCredential } from "./config-dir-credentials.ts";
import { classifySdkError } from "./errors.ts";
import { classifySdkError, sdkAssistantFailure, sdkResultFailure } from "./errors.ts";
import { runFailover } from "./failover.ts";
import { refusalError } from "./refusal.ts";
import type { Options, SDKMessage, SdkQuery } from "./sdk-boundary.ts";
Expand Down Expand Up @@ -143,7 +143,12 @@ async function prepareSlot(
Object.assign(slot, updated);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`authentication_failed: ${detail}`);
const classification = classifySdkError(detail);
throw new Error(
classification.kind === "other" && classification.retryable
? `server_error: ${detail}`
: `authentication_failed: ${detail}`,
);
}
}
const access = slot.source === "env" ? envSlotToken((name) => environment[name], slot.name) : slot.access;
Expand All @@ -157,20 +162,8 @@ async function prepareSlot(
function sdkFailure(message: SDKMessage): unknown | undefined {
const refusal = refusalError(message);
if (refusal) return refusal;
if (message.type === "assistant" && message.error) return message.error;
if (message.type === "result" && message.subtype !== "success") {
const errors = "errors" in message && Array.isArray(message.errors) ? (message.errors as unknown[]) : [];
if (errors.length > 0) return new Error(String(errors[0]));
// `subtype` alone is too coarse to classify: a subscription limit and an
// ordinary tool failure both arrive as "error_during_execution". The SDK
// carries the real cause in `terminal_reason` (e.g. "blocking_limit"), so
// append it — otherwise classifySdkError() scores every result error as
// non-retryable "other", the exhausted account is never blocked, and a
// multi-account pool never rotates past it.
const reason =
"terminal_reason" in message && typeof message.terminal_reason === "string" ? message.terminal_reason : "";
return new Error(reason ? `Claude Code ${message.subtype}: ${reason}` : `Claude Code ${message.subtype}`);
}
if (message.type === "assistant") return sdkAssistantFailure(message);
if (message.type === "result") return sdkResultFailure(message);
return undefined;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
- LOW in `session-sync.ts` around `appendContent`.
- NEW file `content-blocks.ts`.
## 2026-09-03 - Accept a rotation-projected OAuth slot as configured
## Surface SDK error text and classify is_error results (2026-09-03)

### What changed

Expand All @@ -38,6 +39,26 @@
### Expected merge conflict zones

- LOW: `oauth-login.ts` around the `accountCount` computation in `configuredFor`. The same hunk appears in the open PRs #1304 and #1196.
- `errors.ts`: added shared extraction for assistant text and `is_error` result failures, transport classification, and SDK code classifications.
- `auth-lane.ts`: shared SDK failure extraction now carries real text, and transient token refresh failures are marked as server errors instead of permanent auth errors.
- `stream.ts`: assistant and result failures terminate ambient streams with their actual text.
- `session-registry-pump.ts`: `is_error` results reject and close claimed resident turns.
- `session-turn-attempt.ts`: only genuine successful results record a successful turn.
- `guidance.ts`: added version-floor and model-not-found remediation guidance.
- `stream-guidance.ts`: appends actionable binary guidance to surfaced SDK errors.

### Why

- Claude Code emits useful API text beside a bare `unknown` assistant error and can mark an otherwise `success` result as `is_error`; losing either signal hides version failures and prevents session-limit and API-error failover.

### Why an extension could not handle it

- These SDK messages are classified inside the builtin provider's ambient stream, managed auth lane, and resident session pump before any extension-facing result exists.

### Expected merge conflict zones

- MEDIUM in `errors.ts`, `auth-lane.ts`, and `stream.ts` around SDK message failure extraction.
- LOW in `session-registry-pump.ts`, `session-turn-attempt.ts`, `guidance.ts`, and `stream-guidance.ts`.
## 2026-09-02 - Honor tool-less summarization requests

### What changed
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { SDKAssistantMessageError } from "@anthropic-ai/claude-agent-sdk";
import type { SDKMessage } from "./sdk-boundary.ts";

export type SdkErrorKind = "rate_limit" | "overloaded" | "auth_error" | "billing" | "org_not_allowed" | "other";

Expand All @@ -15,6 +16,9 @@ const SDK_ERROR_CLASSIFICATIONS: Partial<Record<SDKAssistantMessageError, SdkErr
overloaded: { kind: "overloaded", retryable: true },
invalid_request: { kind: "other", retryable: false },
server_error: { kind: "other", retryable: true },
account_on_hold: { kind: "billing", retryable: true },
model_not_found: { kind: "other", retryable: false },
max_output_tokens: { kind: "other", retryable: false },
};

const OTHER_ERROR: SdkErrorClassification = { kind: "other", retryable: false };
Expand All @@ -37,11 +41,69 @@ function errorText(error: unknown): string {
return String(error);
}

export type SdkResultUsage = Extract<SDKMessage, { type: "result" }>["usage"];

/** A failed SDK result keeps the usage it billed so every lane can account for it. */
export class SdkResultFailure extends Error {
readonly usage: SdkResultUsage | undefined;

constructor(message: string, usage: SdkResultUsage | undefined) {
super(message);
this.name = "SdkResultFailure";
this.usage = usage;
}
}

/** The usage carried by a failed SDK result, looking through failover's classification wrapper. */
export function sdkResultFailureUsage(error: unknown): SdkResultUsage | undefined {
if (error instanceof SdkResultFailure) return error.usage;
const wrapped = record(error)?.original;
return wrapped instanceof SdkResultFailure ? wrapped.usage : undefined;
}

export function sdkResultFailure(message: Extract<SDKMessage, { type: "result" }>): SdkResultFailure | undefined {
if (message.subtype === "success" && message.is_error !== true) return undefined;
const errors = "errors" in message && Array.isArray(message.errors) ? message.errors : [];
const firstError = errors.find((error): error is string => typeof error === "string" && error.length > 0);
const resultText = "result" in message && typeof message.result === "string" ? message.result.trim() : "";
const detail =
firstError ??
(message.is_error === true && resultText ? resultText : undefined) ??
`Claude Code ${message.subtype}`;
const status =
"api_error_status" in message && message.api_error_status != null ? `HTTP ${message.api_error_status}` : "";
const reason =
"terminal_reason" in message && typeof message.terminal_reason === "string" ? message.terminal_reason : "";
const suffix = [status, reason].filter(Boolean).join(", ");
return new SdkResultFailure(suffix ? `${detail} (${suffix})` : detail, message.usage);
}

export function sdkAssistantFailure(message: Extract<SDKMessage, { type: "assistant" }>): Error | undefined {
if (!message.error) return undefined;
const content = message.message.content;
const text = (
typeof content === "string"
? content
: content
.filter((block) => block.type === "text" && "text" in block && typeof block.text === "string")
.map((block) => ("text" in block && typeof block.text === "string" ? block.text : ""))
.join(" ")
).trim();
return new Error(text ? (message.error === "unknown" ? text : `${text} (${message.error})`) : message.error);
}

/** Classifies Claude SDK OAuth error codes and HTTP-shaped fallback text in one place. */
export function classifySdkError(error: unknown): SdkErrorClassification {
const text = errorText(error).toLowerCase();
if (
/\b(enotfound|eai_again|econnreset|econnrefused|etimedout|enetunreach|ehostunreach|und_err_connect_timeout|und_err_socket)\b|fetch failed|socket hang up|connection reset by peer/.test(
text,
)
) {
return { kind: "other", retryable: true };
}
for (const [code, classification] of Object.entries(SDK_ERROR_CLASSIFICATIONS)) {
if (new RegExp(`\\b${code}\\b`).test(text)) return classification;
if (new RegExp(`\\b${code}\\b`).test(text)) return classification ?? OTHER_ERROR;
}
if (/\b(?:http\s*)?429\b|too many requests|rate[ _-]?limit/.test(text)) {
return { kind: "rate_limit", retryable: true };
Expand All @@ -61,5 +123,8 @@ export function classifySdkError(error: unknown): SdkErrorClassification {
return { kind: "rate_limit", retryable: true };
}
if (/\b(?:http\s*)?529\b|overloaded/.test(text)) return { kind: "overloaded", retryable: true };
if (/\binvalid_grant\b|\binvalid_token\b|\b(?:http\s*)?401\b|\bunauthorized\b/.test(text)) {
return { kind: "auth_error", retryable: true };
}
return OTHER_ERROR;
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,21 @@ export function allAccountsBlockedGuidance(soonestUnblockAt: number | undefined)
].join("\n");
}

export function claudeCodeVersionFloorGuidance(text: string): string | undefined {
const floor = /does not support this model; version (\S+?) or newer is required|claude_code_version_too_old/i.exec(
text,
);
if (floor) {
const target =
floor[1] === undefined ? "a newer Claude Code" : `Claude Code ${floor[1].replace(/[.,;:]+$/, "")} or newer`;
return `The bundled Claude Code binary is too old for this model. Update senpi/omo (it ships a newer @anthropic-ai/claude-agent-sdk) or set CLAUDE_CODE_EXECUTABLE to ${target} binary.`;
}
if (/\bmodel_not_found\b|unrecognized_model|not found for provider/i.test(text)) {
return "The bundled Claude Code binary does not know this model id; update senpi/omo or set CLAUDE_CODE_EXECUTABLE to a newer Claude Code binary.";
}
return undefined;
}

export function sdkErrorGuidance(kind: SdkErrorKind): string | undefined {
switch (kind) {
case "org_not_allowed":
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { sdkResultFailure } from "./errors.ts";
import { refusalError } from "./refusal.ts";
import type { SDKMessage, SDKUserMessage } from "./sdk-boundary.ts";
import { evaluateAbortOutcome } from "./session-reattach.ts";
Expand Down Expand Up @@ -124,7 +125,14 @@ function handleMessage(
if (isReplayFor(message, turn.uuid)) claimTurn(entry, turn);
else if (message.type === "stream_event") bufferBeforeReplay(registry, entry, turn, message);
else if (message.type === "result") {
throw new SessionTurnAttributionError("Claude SDK OAuth result arrived before replay claim");
// A result that fails before the SDK ever echoed our user message (a
// 400 version floor, a session limit) must surface as that failure so
// failover can classify and rotate; only a genuine success-before-claim
// is an attribution error.
throw (
sdkResultFailure(message) ??
new SessionTurnAttributionError("Claude SDK OAuth result arrived before replay claim")
);
}
return false;
}
Expand All @@ -134,8 +142,14 @@ function handleMessage(
failTurn(registry, entry, refusal);
return true;
}
if (message.type === "result") finishTurn(registry, entry, turn, message);
else deliver(entry, turn, message);
if (message.type === "result") {
const failure = sdkResultFailure(message);
if (failure) {
failTurn(registry, entry, failure);
return true;
}
finishTurn(registry, entry, turn, message);
} else deliver(entry, turn, message);
return false;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BoundedAsyncQueue, SESSION_STREAM_QUEUE_CAPACITY } from "./bounded-queue.ts";
import { sdkResultFailure } from "./errors.ts";
import type { SDKMessage, SDKUserMessage } from "./sdk-boundary.ts";
import { bindingFromEntry, rememberBinding } from "./session-reattach.ts";
import {
Expand All @@ -13,7 +14,10 @@ import { recordSyncedStream, sentHashPrefixDigest } from "./session-sync.ts";
type StagedContinuityDecision = { emit(): void };

function successfulTurn(messages: readonly SDKMessage[]): boolean {
return messages.some((message) => message.type === "result" && message.subtype === "success");
return messages.some(
(message) =>
message.type === "result" && message.subtype === "success" && sdkResultFailure(message) === undefined,
);
}

function recordAssistantUuid(entry: ClaudeSdkOauthSessionEntry, sentCount: number, message: SDKMessage): void {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { AllAccountsBlockedError } from "./affinity.ts";
import { classifySdkError } from "./errors.ts";
import { allAccountsBlockedGuidance, sdkErrorGuidance } from "./guidance.ts";
import { allAccountsBlockedGuidance, claudeCodeVersionFloorGuidance, sdkErrorGuidance } from "./guidance.ts";

export function withAuthGuidance(error: unknown, message: string): string {
if (error instanceof AllAccountsBlockedError) return allAccountsBlockedGuidance(error.soonestUnblockAt);
const guidance = sdkErrorGuidance(classifySdkError(error).kind);
return guidance ? `${message}\n${guidance}` : message;
const versionGuidance = claudeCodeVersionFloorGuidance(message);
const hints = [guidance, versionGuidance].filter((hint): hint is string => hint !== undefined);
return hints.length > 0 ? `${message}\n${hints.join("\n")}` : message;
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { getSessionClaudeAccountPin } from "./account-command.ts";
import { queryWithAuthLane } from "./auth-lane.ts";
import { buildCustomToolServers } from "./custom-tools.ts";
import { sdkAssistantFailure, sdkResultFailure, sdkResultFailureUsage } from "./errors.ts";
import { defaultExecutableDeps, resolveClaudeCodeExecutable } from "./executable.ts";
import { buildClaudeSdkOauthQueryOptions } from "./options.ts";
import { buildPromptBlocks, buildPromptStream } from "./prompt-bridge.ts";
Expand Down Expand Up @@ -155,6 +156,13 @@ export function streamClaudeSdkOauth(
for await (const message of messages) {
const refusal = refusalError(message);
if (refusal) throw refusal;
const failure =
message.type === "assistant"
? sdkAssistantFailure(message)
: message.type === "result"
? sdkResultFailure(message)
: undefined;
if (failure) throw failure;
if (!started) {
stream.push({ type: "start", partial: output });
started = true;
Expand Down Expand Up @@ -195,12 +203,6 @@ export function streamClaudeSdkOauth(
output.stopReason = mapStopReason(message.stop_reason);
}
if (!sawStreamEvent) output.content.push({ type: "text", text: message.result });
} else if (message.type === "result") {
const reason =
"errors" in message && Array.isArray(message.errors) && message.errors.length > 0
? String(message.errors[0])
: `Claude Code ${message.subtype}`;
throw new Error(reason);
}
}

Expand All @@ -219,6 +221,10 @@ export function streamClaudeSdkOauth(
// no-excuse-ok: catch
// Provider boundary converts every thrown SDK value into the stream error contract.
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
// A failed result still bills its tokens; managed and resident lanes
// throw before the result reaches this loop, so account for it here.
const billed = sdkResultFailureUsage(error);
if (billed) updateUsage(model, output, billed);
output.errorMessage = withAuthGuidance(error, errorMessage(error));
stream.push({ type: "error", reason: output.stopReason, error: output });
} finally {
Expand Down
61 changes: 61 additions & 0 deletions packages/coding-agent/test/claude-sdk-oauth-errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import * as errors from "../src/core/extensions/builtin/claude-sdk-oauth/errors.ts";

const versionFloor = {
type: "result",
subtype: "success",
is_error: true,
api_error_status: 400,
terminal_reason: "api_error",
result:
"API Error: 400 Claude Code 2.1.241 does not support this model; version 2.1.251 or newer is required. Run 'claude update', or update the Claude desktop app, then try again.",
modelUsage: {},
};

function exported(name: string): unknown {
return Reflect.get(errors, name);
}

describe("Claude SDK OAuth error extraction", () => {
it("surfaces result text, status, and terminal reason from real SDK result shapes", () => {
const failure = exported("sdkResultFailure");
expect(typeof failure).toBe("function");
if (typeof failure !== "function") return;
const error = failure(versionFloor);
expect(error).toBeInstanceOf(Error);
expect(error.message).toContain("does not support this model");
expect(error.message).toContain("HTTP 400, api_error");
expect(failure({ type: "result", subtype: "success", is_error: false, result: "ok" })).toBeUndefined();
expect(
failure({
type: "result",
subtype: "error_during_execution",
is_error: true,
errors: ["You've hit your session limit"],
}),
).toMatchObject({ message: "You've hit your session limit" });
});

it("prefers assistant text over unknown while retaining informative SDK codes", () => {
const failure = exported("sdkAssistantFailure");
expect(typeof failure).toBe("function");
if (typeof failure !== "function") return;
const assistant = (error: string, text: string) => ({
type: "assistant",
error,
message: { content: [{ type: "text", text }] },
});
expect(failure(assistant("unknown", versionFloor.result))).toMatchObject({ message: versionFloor.result });
const rateLimit = failure(assistant("rate_limit", "You've hit your session limit"));
expect(rateLimit.message).toMatch(/\(rate_limit\)$/);
expect(errors.classifySdkError(rateLimit)).toEqual({ kind: "rate_limit", retryable: true });
});

it("distinguishes transient refresh transport failures from rejected credentials", () => {
expect(errors.classifySdkError("authentication_failed: getaddrinfo ENOTFOUND platform.claude.com")).toEqual({
kind: "other",
retryable: true,
});
expect(errors.classifySdkError("invalid_grant")).toEqual({ kind: "auth_error", retryable: true });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ describe("Claude SDK OAuth failover", () => {
kind: "other",
retryable: false,
});
expect(classifySdkError("connection reset by peer")).toEqual({ kind: "other", retryable: false });
expect(classifySdkError("connection reset by peer")).toEqual({ kind: "other", retryable: true });
});

it("walks HRW order after a rate limit, persists the cooldown, and emits failover", async () => {
Expand Down
Loading