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 docs-site/src/content/docs/fr/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -775,3 +775,5 @@ Claude Code **2.1.257 or newer** is required for FORCE. Plugin and built-in agen
The dashboard warns about old or unknown CLI versions, unavailable targets, and either variable already present in `settings.json` → `env` (which overrides launch env). Detection is read-only and server-local: it cannot inspect another launch shell, another machine, or project-local settings. An unknown result is not proof of force support.

Explicit gateway selectors on a generated agent request take precedence over its legacy `ocx-route` fallback, even if the saved force setting changes after launch. For shell or settings overrides of generated roster agents, use an explicit gateway alias; bare Claude ids retain the older-client fallback behavior. Native aliases restore their bare model before the existing credential and model-map checks. Connected launches validate force targets against a fresh authenticated gateway catalog; failed discovery skips automatic force injection, and cached context windows alone never prove availability.

Uniquement avant toute sortie : Un HTTP 401 authentication_error (sans error.code) portant exactement le message « OAuth access token has been revoked. » marque le compte OAuth ayant envoyé la requête comme nécessitant une nouvelle connexion et efface ses affinités de session. Avant toute sortie, un compte disponible du même pool peut prendre le relais dans les limites existantes. Sans remplaçant, le 401 original est renvoyé et le compte reste exclu jusqu’à une nouvelle connexion. Les autres 401 gardent leur traitement actuel.
6 changes: 6 additions & 0 deletions docs-site/src/content/docs/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ Operational contract when enabled:
access, policy and unrecognized errors stay terminal. Recovery respects model routes and
send limits; if no replacement is eligible, the original 403 is returned. This also works
with proactive pooling off. A 403 after assistant output starts never switches accounts.
- Before output, an exact structured **401** authentication_error with no error code and the message
“OAuth access token has been revoked.” marks the sending OAuth account as requiring
a new login and clears its session affinities. Before output, an eligible account in
the same pool may take over within existing send limits. With no eligible replacement,
the original 401 is returned; the refused account remains excluded until login.
Other 401 errors retain their existing behavior.
- Token-refresh credential failures retain the existing `needsReauth` policy. Subscription
renewal does not require reauthentication, but the account waits for its cooldown to expire.
- If every eligible account is cooling, the proxy returns **429** (not 401) with `Retry-After`
Expand Down
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/ja/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -630,3 +630,5 @@ Claude Code **2.1.257 or newer** is required for FORCE. Plugin and built-in agen
The dashboard warns about old or unknown CLI versions, unavailable targets, and either variable already present in `settings.json` → `env` (which overrides launch env). Detection is read-only and server-local: it cannot inspect another launch shell, another machine, or project-local settings. An unknown result is not proof of force support.

Explicit gateway selectors on a generated agent request take precedence over its legacy `ocx-route` fallback, even if the saved force setting changes after launch. For shell or settings overrides of generated roster agents, use an explicit gateway alias; bare Claude ids retain the older-client fallback behavior. Native aliases restore their bare model before the existing credential and model-map checks. Connected launches validate force targets against a fresh authenticated gateway catalog; failed discovery skips automatic force injection, and cached context windows alone never prove availability.

出力前の応答に限り、HTTP 401 の authentication_error(error.code なし) が正確に “OAuth access token has been revoked.” を返した場合、送信した OAuth アカウントを再ログインが必要な状態にし、セッションの紐付けを解除します。出力前に限り、既存の送信上限内で同じプールの利用可能なアカウントに切り替えます。候補がなければ元の 401 を返し、再ログインまで選択から除外します。他の 401 の処理は変わりません。
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/ko/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -693,3 +693,5 @@ Claude Code **2.1.257 or newer** is required for FORCE. Plugin and built-in agen
The dashboard warns about old or unknown CLI versions, unavailable targets, and either variable already present in `settings.json` → `env` (which overrides launch env). Detection is read-only and server-local: it cannot inspect another launch shell, another machine, or project-local settings. An unknown result is not proof of force support.

Explicit gateway selectors on a generated agent request take precedence over its legacy `ocx-route` fallback, even if the saved force setting changes after launch. For shell or settings overrides of generated roster agents, use an explicit gateway alias; bare Claude ids retain the older-client fallback behavior. Native aliases restore their bare model before the existing credential and model-map checks. Connected launches validate force targets against a fresh authenticated gateway catalog; failed discovery skips automatic force injection, and cached context windows alone never prove availability.

출력 전의 응답에서만 정확한 HTTP 401 authentication_error (error.code 없음) 메시지가 “OAuth access token has been revoked.”이면 요청을 보낸 OAuth 계정에 재로그인이 필요하다고 표시하고 세션 연결을 해제합니다. 출력 전에는 기존 전송 제한 안에서 같은 풀의 사용 가능한 계정으로 전환할 수 있습니다. 대체 계정이 없으면 원래 401을 반환하며, 해당 계정은 재로그인할 때까지 선택에서 제외됩니다. 다른 401의 처리는 바뀌지 않습니다.
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/ru/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -665,3 +665,5 @@ Claude Code **2.1.257 or newer** is required for FORCE. Plugin and built-in agen
The dashboard warns about old or unknown CLI versions, unavailable targets, and either variable already present in `settings.json` → `env` (which overrides launch env). Detection is read-only and server-local: it cannot inspect another launch shell, another machine, or project-local settings. An unknown result is not proof of force support.

Explicit gateway selectors on a generated agent request take precedence over its legacy `ocx-route` fallback, even if the saved force setting changes after launch. For shell or settings overrides of generated roster agents, use an explicit gateway alias; bare Claude ids retain the older-client fallback behavior. Native aliases restore their bare model before the existing credential and model-map checks. Connected launches validate force targets against a fresh authenticated gateway catalog; failed discovery skips automatic force injection, and cached context windows alone never prove availability.

Только до начала вывода: Если HTTP 401 содержит authentication_error (без error.code) с точным сообщением “OAuth access token has been revoked.”, отправившая запрос учётная запись OAuth помечается как требующая нового входа, а привязки сессий очищаются. До начала вывода возможен переход к доступной записи того же пула в пределах существующих лимитов отправки. Без замены возвращается исходный 401; запись исключается до нового входа. Обработка других 401 не меняется.
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/tr/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -887,3 +887,5 @@ Claude Code **2.1.257 or newer** is required for FORCE. Plugin and built-in agen
The dashboard warns about old or unknown CLI versions, unavailable targets, and either variable already present in `settings.json` → `env` (which overrides launch env). Detection is read-only and server-local: it cannot inspect another launch shell, another machine, or project-local settings. An unknown result is not proof of force support.

Explicit gateway selectors on a generated agent request take precedence over its legacy `ocx-route` fallback, even if the saved force setting changes after launch. For shell or settings overrides of generated roster agents, use an explicit gateway alias; bare Claude ids retain the older-client fallback behavior. Native aliases restore their bare model before the existing credential and model-map checks. Connected launches validate force targets against a fresh authenticated gateway catalog; failed discovery skips automatic force injection, and cached context windows alone never prove availability.

Yalnızca çıktı başlamadan önce: HTTP 401 authentication_error (error.code olmadan) iletisi tam olarak “OAuth access token has been revoked.” olduğunda, isteği gönderen OAuth hesabı yeniden oturum açılması gereken durumda işaretlenir ve oturum bağları temizlenir. Çıktı başlamadan önce mevcut gönderim sınırları içinde aynı havuzdaki uygun hesaba geçilebilir. Alternatif yoksa özgün 401 döndürülür; hesap yeniden giriş yapılana kadar seçim dışı kalır. Diğer 401 yanıtlarının işlenmesi değişmez.
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/zh-cn/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -596,3 +596,5 @@ Claude Code **2.1.257 or newer** is required for FORCE. Plugin and built-in agen
The dashboard warns about old or unknown CLI versions, unavailable targets, and either variable already present in `settings.json` → `env` (which overrides launch env). Detection is read-only and server-local: it cannot inspect another launch shell, another machine, or project-local settings. An unknown result is not proof of force support.

Explicit gateway selectors on a generated agent request take precedence over its legacy `ocx-route` fallback, even if the saved force setting changes after launch. For shell or settings overrides of generated roster agents, use an explicit gateway alias; bare Claude ids retain the older-client fallback behavior. Native aliases restore their bare model before the existing credential and model-map checks. Connected launches validate force targets against a fresh authenticated gateway catalog; failed discovery skips automatic force injection, and cached context windows alone never prove availability.

仅在输出开始前的响应中,仅当 HTTP 401 的 authentication_error(无 error.code) 消息完全等于 “OAuth access token has been revoked.” 时,发送请求的 OAuth 账户会被标记为需要重新登录,并清除会话绑定。输出开始前,可在现有发送限制内切换到同一池的可用账户;没有替代账户时返回原始 401,该账户在重新登录前不会被选择。其他 401 的处理保持不变。
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/zh-tw/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -672,3 +672,5 @@ Claude Code **2.1.257 or newer** is required for FORCE. Plugin and built-in agen
The dashboard warns about old or unknown CLI versions, unavailable targets, and either variable already present in `settings.json` → `env` (which overrides launch env). Detection is read-only and server-local: it cannot inspect another launch shell, another machine, or project-local settings. An unknown result is not proof of force support.

Explicit gateway selectors on a generated agent request take precedence over its legacy `ocx-route` fallback, even if the saved force setting changes after launch. For shell or settings overrides of generated roster agents, use an explicit gateway alias; bare Claude ids retain the older-client fallback behavior. Native aliases restore their bare model before the existing credential and model-map checks. Connected launches validate force targets against a fresh authenticated gateway catalog; failed discovery skips automatic force injection, and cached context windows alone never prove availability.

僅在輸出開始前的回應中,僅當 HTTP 401 的 authentication_error(無 error.code) 訊息完全等於 “OAuth access token has been revoked.” 時,發送請求的 OAuth 帳戶會被標記為需要重新登入,並清除工作階段綁定。輸出開始前,可在既有發送限制內切換到同一池的可用帳戶;沒有替代帳戶時回傳原始 401,該帳戶在重新登入前不會被選取。其他 401 的處理保持不變。
5 changes: 5 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
"explicit": {
"messages-request-id-headers.test.ts": "claude-integration",
"messages-request-id-endpoint.test.ts": "claude-integration",
"anthropic-revoked-token.test.ts": "adapters/anthropic",
"messages-revoked-token.test.ts": "claude-integration",
"anthropic-revoked-token-continuation.test.ts": "adapters/anthropic",
"anthropic-revoked-token-sidecars.test.ts": "adapters/anthropic",
"anthropic-revoked-token-boundaries.test.ts": "adapters/anthropic",
"azure-vendor-metadata.test.ts": "providers",
"anthropic-instance-isolation.test.ts": "adapters/anthropic", "anthropic-instance-pool-parity.test.ts": "adapters/anthropic",
"anthropic-instance-quota.test.ts": "adapters/anthropic", "anthropic-instance-recovery.test.ts": "adapters/anthropic",
Expand Down
2 changes: 1 addition & 1 deletion src/images/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -704,7 +704,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
}
// 429 key-failover parity with web-search / normal routed path.
while ((prepared.response.status === 429
|| (prepared.response.status === 403 && isAnthropicInstanceId(deps.incomingMeta?.providerName))
|| ((prepared.response.status === 403 || prepared.response.status === 401) && isAnthropicInstanceId(deps.incomingMeta?.providerName))
|| (iterParsed._kiroAuthContext && (prepared.response.status === 400 || prepared.response.status === 403))) && deps.on429) {
const rotated = await deps.on429(prepared.response.headers.get("retry-after"), prepared.response.headers,
iterParsed, prepared.response);
Expand Down
40 changes: 39 additions & 1 deletion src/oauth/anthropic-account-refusal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { anthropicModelFamily } from "./anthropic-model-quota";
import { readBoundedResponseBody } from "../lib/bounded-body";
import { classifyAnthropic429, anthropicRetryAfterMs, anthropicRatePolicyFor, ANTHROPIC_SHORT_RETRY_MS, ANTHROPIC_MAX_INLINE_THROTTLE_MS } from "./anthropic-rate-limit-policy";
import { isNonReplayableResponse, sleepWithAbort } from "../lib/upstream-retry";
import { credentialGeneration, getAccountCredentialWithStatus } from "./store";
import { credentialGeneration, getAccountCredentialWithStatus, markAccountNeedsReauthIfGeneration } from "./store";
import type { OAuthAccessSnapshot } from "./index";
import type { OcxConfig } from "../types";
import type { AnthropicRouteDecision } from "./anthropic-model-routes";
Expand Down Expand Up @@ -31,6 +31,22 @@ export function bindAnthropicRefusalCredentialForSend(response: Response, owner:
responseCredentials.set(response, Object.freeze({ ...owner, providerAccountUuid, checkProviderUuid: arguments.length >= 3 }));
}

async function isRevokedOAuthToken(response: Response, signal?: AbortSignal): Promise<boolean> {
if (response.status !== 401) return false;
try {
const body = await readBoundedResponseBody(response.clone(), { signal, fatalUtf8: true });
if (!body.displaySafe || body.truncated) return false;
const payload: unknown = JSON.parse(body.text);
if (!payload || typeof payload !== "object" || Array.isArray(payload)
|| !("type" in payload) || payload.type !== "error" || !("error" in payload)) return false;
const error = payload.error;
return !!error && typeof error === "object" && !Array.isArray(error)
&& "type" in error && error.type === "authentication_error"
&& "message" in error && error.message === "OAuth access token has been revoked."
&& (!("code" in error) || error.code == null);
} catch { return false; }
}

async function isAccountRefusal(response: Response, signal?: AbortSignal): Promise<boolean> {
try {
const body = await readBoundedResponseBody(response.clone(), { signal });
Expand Down Expand Up @@ -91,6 +107,28 @@ export async function rotateAnthropicAccountOnResponseForInstance(
&& credentialGeneration(row.credential) === sent.generation
&& (!sent.checkProviderUuid || row.credential.accountId === sent.providerAccountUuid) ? row : undefined;
};
if (response.status === 401) {
if (options.allowAccountRefusal === false) return null;
let verdict = verdicts.get(response);
if (!verdict) { verdict = isRevokedOAuthToken(response, options.signal); verdicts.set(response, verdict); }
if (!await verdict || options.signal?.aborted || !ownedCurrent()) return null;
options.currentDecision?.();
let marked: boolean;
try {
marked = await markAccountNeedsReauthIfGeneration(instance, sent.accountId, sent.generation, undefined, undefined, store => {
const row = store[instance]?.accounts.find(account => account.id === sent.accountId);
return !options.signal?.aborted && configuredAnthropicInstance(options.config, instance) === instance
&& anthropicPhysicalSendOwnershipIsCurrent(sent, store)
&& (!sent.checkProviderUuid || row?.credential.accountId === sent.providerAccountUuid);
});
} catch { return null; }
if (!marked || configuredAnthropicInstance(options.config, instance) !== instance
|| !anthropicPhysicalSendOwnershipIsCurrent(sent)) return null;
routing.clearAnthropicSessionAffinityForAccount(sent.accountId);
if (!options.canRetry || options.signal?.aborted) return null;
const decision = options.currentDecision ? options.currentDecision() : options.decision ?? null;
return pickAlternateAnthropicAccount(options.config, sent.accountId, Date.now(), decision, options.model, options.excludedAccountIds);
}
if (response.status === 429) {
const current = ownedCurrent();
if (!current) return null;
Expand Down
2 changes: 1 addition & 1 deletion src/oauth/anthropic-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -704,7 +704,7 @@ function createAnthropicRouting(instance: AnthropicInstanceId): AnthropicRouting
excludedAccountIds?: ReadonlySet<string>,
): string | null {
if (!admitted(config)) return null;
const strategy = anthropicPoolStrategy(config);
const strategy = isAnthropicAccountPoolEnabled(config) ? anthropicPoolStrategy(config) : "quota";
const eligible = routeCandidates(getEligibleAnthropicAccounts(now, model), decision).filter(id => id !== excludeId && !excludedAccountIds?.has(id));
if (strategy === "round-robin") {
return peekRoundRobinAccount(poolKey, eligible, stickyLimitForPool(config));
Expand Down
6 changes: 3 additions & 3 deletions src/oauth/anthropic-send-ownership.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** Ownership captured before a physical send, independent of subsequent cooldown observations. */
import type { OAuthAccessSnapshot } from "./index";
import { credentialGeneration, getAccountSet } from "./store";
import { credentialGeneration, getAccountSet, type AuthStore } from "./store";
import { isAnthropicInstanceId, type AnthropicInstanceId } from "../providers/anthropic-instance-id";
import { anthropicCooldownRecoveryFor } from "../providers/quota/anthropic-cooldown-recovery";
import { captureProviderAccountQuotaEpoch } from "../providers/quota/account-cache";
Expand Down Expand Up @@ -32,8 +32,8 @@ export function captureAnthropicPhysicalSendOwnership(snapshot: OAuthAccessSnaps
}

/** Pure ownership read: never adopt or reserve the replacement account's current incarnation. */
export function anthropicPhysicalSendOwnershipIsCurrent(owner: AnthropicPhysicalSendOwnership): boolean {
const row = getAccountSet(owner.provider)?.accounts.find(account => account.id === owner.accountId);
export function anthropicPhysicalSendOwnershipIsCurrent(owner: AnthropicPhysicalSendOwnership, store?: AuthStore): boolean {
const row = (store ? store[owner.provider] : getAccountSet(owner.provider))?.accounts.find(account => account.id === owner.accountId);
return !!row && row.loginId === owner.loginId && row.addedAt === owner.addedAt
&& row.credential.access === owner.accessToken && credentialGeneration(row.credential) === owner.generation
&& anthropicCooldownRecoveryFor(owner.provider).anthropicAccountIncarnation(owner.accountId) === owner.accountIncarnation
Expand Down
Loading
Loading