[eric] providers: an Anthropic key that spans workspaces sends anthropic-workspace-id on every lane, with a Workspace ID field beside the key and a card that names the fix

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C9zwUaHucUgrdxvK8FvjYT
This commit is contained in:
ciregenz
2026-09-03 12:44:30 -07:00
co-authored by Claude Fable 5.1
parent f06643fcd6
commit 48886e4746
14 changed files with 204 additions and 9 deletions
+3 -2
View File
@@ -704,6 +704,7 @@ async def probe_model(body: dict):
import anthropic
client = None
from backend.apps.settings.credentials import own_key_anthropic_client as p_own_key_client
# Routing mirrors agent_manager: prefix takes precedence over Pro.
resolved_is_9router = (
isinstance(resolved, str)
@@ -715,7 +716,7 @@ async def probe_model(body: dict):
return {"ok": True, "skipped": True}
client = anthropic.AsyncAnthropic(api_key="9router", base_url="http://localhost:20128")
elif route == "api" and api_type == "anthropic" and getattr(settings, "anthropic_api_key", None):
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
client = p_own_key_client(settings)
elif api_type == "anthropic" and connection_mode == "openswarm-pro":
bearer = getattr(settings, "openswarm_bearer_token", "") or ""
proxy_url = (getattr(settings, "openswarm_proxy_url", None) or "https://api.openswarm.com").rstrip("/")
@@ -723,7 +724,7 @@ async def probe_model(body: dict):
return {"ok": True, "skipped": True}
client = anthropic.AsyncAnthropic(auth_token=bearer, base_url=proxy_url)
elif api_type == "anthropic" and getattr(settings, "anthropic_api_key", None):
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
client = p_own_key_client(settings)
else:
if not p_9r_running():
return {"ok": True, "skipped": True}
@@ -308,6 +308,16 @@ def is_translation_error(exc: BaseException, extra_text: str = "") -> bool:
return bool(P_TRANSLATION_ERROR_PATTERNS.search(combined))
# Anthropic's own wording for an identity-linked key sent without a workspace id (400), with one it
# cannot use (404 "Workspace `wrkspc_...` not found."), or with a malformed one.
P_WORKSPACE_ID_RE = re.compile(r"anthropic-workspace-id|workspace\s+`?wrkspc_[\w-]*`?\s+not\s+found", re.IGNORECASE)
def is_workspace_id_error(exc: BaseException, extra_text: str = "") -> bool:
"""True when Anthropic refused the request over the workspace id, not the key itself."""
return bool(P_WORKSPACE_ID_RE.search(f"{exc!s}\n{extra_text}"))
@typechecked
def is_auth_error(exc: BaseException, extra_text: str = "") -> bool:
"""True when the upstream error is a 401/403 auth failure.
@@ -320,6 +330,10 @@ def is_auth_error(exc: BaseException, extra_text: str = "") -> bool:
combined = f"{exc!s}\n{extra_text}".strip()
if not combined:
return False
# A missing or wrong workspace id is a credential problem the user fixes in Settings; it used to land
# here only because "invalid_request_error ... API key" happened to match the generic regex below.
if is_workspace_id_error(exc, extra_text):
return True
# A tool-schema translation 400 can carry provider/connection wording that trips the auth regex below; it isn't auth, so don't claim it is.
if is_translation_error(exc, extra_text):
return False
@@ -63,8 +63,9 @@ async def configure_provider_env(
api_route_provider = (model_entry or {}).get("api") if is_pinned_api_route else None
if is_pinned_api_route and api_route_provider == "anthropic" and getattr(global_settings, "anthropic_api_key", None):
from backend.apps.settings.credentials import own_key_cli_env as p_own_key_cli_env
options_kwargs["env"] = {
"ANTHROPIC_API_KEY": global_settings.anthropic_api_key,
**p_own_key_cli_env(global_settings),
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
# Pin subagents so they don't drift back to the proxy.
"CLAUDE_CODE_SUBAGENT_MODEL": "claude-sonnet-4-6",
@@ -177,7 +178,8 @@ async def configure_provider_env(
options_kwargs["env"]["CLAUDE_CODE_SUBAGENT_MODEL"] = "claude-haiku-4-5-20251001"
logger.info(f"[MCP-DEBUG] Using OpenSwarm cloud proxy at {proxy_url}")
elif api_type == "anthropic" and not resolved_is_9router and global_settings.anthropic_api_key:
options_kwargs["env"] = {"ANTHROPIC_API_KEY": global_settings.anthropic_api_key}
from backend.apps.settings.credentials import own_key_cli_env as p_own_key_cli_env_fallback
options_kwargs["env"] = p_own_key_cli_env_fallback(global_settings)
logger.info("[MCP-DEBUG] Using direct Anthropic API key")
elif await router_available(global_settings):
# Gemini-bound ids go through the local proxy for schema scrubbing; everything else hits 9Router directly.
@@ -8,7 +8,12 @@ import logging
from typing import List
from typeguard import typechecked
from backend.apps.agents.manager.streaming.provider_error_speech import CODEX_ROTATION_RESEND_NOTICE, CODEX_ROTATION_RETRY_NOTICE
from backend.apps.agents.manager.streaming.provider_error_speech import (
ANTHROPIC_WORKSPACE_ID_MISSING_NOTICE,
ANTHROPIC_WORKSPACE_ID_WRONG_NOTICE,
CODEX_ROTATION_RESEND_NOTICE,
CODEX_ROTATION_RETRY_NOTICE,
)
from backend.apps.agents.core.models import AgentSession, Message
from backend.apps.agents.core.ws_manager import ws_manager
@@ -25,6 +30,7 @@ from backend.apps.agents.core.error_classify import (
is_out_of_tokens,
has_auth_status,
is_auth_error,
is_workspace_id_error,
is_content_policy_block,
is_cert_failure,
is_cli_binary_missing,
@@ -537,7 +543,12 @@ async def handle_run_error(e: Exception, session: AgentSession, session_id: str,
logger.info(f"auth self-heal armed for {session_id} (codex_rotation={p_codex_rotation})")
return
# Codex/OpenAI subscription tokens rotate every ~2-3 minutes, the user sees the rotation window as a 401 with "reset after 1m 59s" or similar. Don't ask them to reconnect; just tell them to wait it out and retry.
if (
# First, above every other reading: the key is fine, the workspace id is missing or wrong.
if is_workspace_id_error(e, extra_text=p_stderr_tail):
p_has_workspace = bool((getattr(load_settings(), "anthropic_workspace_id", None) or "").strip())
friendly_msg = ANTHROPIC_WORKSPACE_ID_WRONG_NOTICE if p_has_workspace else ANTHROPIC_WORKSPACE_ID_MISSING_NOTICE
reason = "anthropic_workspace_id"
elif (
("codex/" in p_combined or "[codex/" in p_combined or p_model.startswith(("cx/", "gpt-")))
and ("authentication token is expired" in p_combined or "authentication token has expired" in p_combined or has_auth_status(p_combined))
):
@@ -266,6 +266,18 @@ CODEX_ROTATION_RETRY_NOTICE = (
"GPT subscription token just rotated (automatic, every couple minutes). Retrying your request "
"automatically in about a minute, no action needed."
)
# Anthropic's personal and service-account keys can span workspaces; such a key needs the workspace id
# on every request, and the generic "re-enter your key" card sent one user around in circles (2026-09-03).
ANTHROPIC_WORKSPACE_ID_MISSING_NOTICE = (
"Your Anthropic API key is linked to your account and can act in more than one workspace, so "
"Anthropic needs to know which one to use. Open Settings > Models and enter the Workspace ID next "
"to your Anthropic key. You can find it in the ID column at platform.claude.com/settings/workspaces."
)
ANTHROPIC_WORKSPACE_ID_WRONG_NOTICE = (
"Anthropic did not accept the Workspace ID saved next to your Anthropic key. Open Settings > Models "
"and check it against the ID column at platform.claude.com/settings/workspaces; if the key is scoped "
"to a single workspace, leave the field empty."
)
CODEX_ROTATION_RESEND_NOTICE = (
"GPT subscription token just rotated (automatic, every couple minutes). Send your message again "
"in about a minute and it will go through; nothing to reconnect. If it keeps happening, open "
@@ -358,9 +358,11 @@ def p_pick_upstream(model: str) -> tuple[str, dict[str, str]]:
return (proxy, {"Authorization": f"Bearer {bearer}"})
ak = getattr(s, "anthropic_api_key", "") or ""
if ak.strip():
from backend.apps.settings.credentials import anthropic_workspace_header as p_workspace_header
return ("https://api.anthropic.com", {
"x-api-key": ak.strip(),
"anthropic-version": "2023-06-01",
**p_workspace_header(s),
})
return ("http://127.0.0.1:20128", {"x-api-key": "9router"})
+29 -1
View File
@@ -125,6 +125,34 @@ def get_provider_credentials(settings: AppSettings, provider: str) -> dict[str,
raise ValueError(f"No credentials for provider: {provider}")
def anthropic_workspace_header(settings: AppSettings) -> dict[str, str]:
"""Anthropic's identity-linked keys (personal and service-account) can span workspaces; such a key
must say which workspace every request acts in or the API answers 400 before the model is reached."""
workspace = (getattr(settings, "anthropic_workspace_id", None) or "").strip()
return {"anthropic-workspace-id": workspace} if workspace else {}
def own_key_anthropic_client(settings: AppSettings) -> anthropic.AsyncAnthropic:
"""The user's own Anthropic key as an SDK client. Every aux call on the key lane builds its client
here, so the workspace header cannot be present on one door and missing on another."""
import anthropic
return anthropic.AsyncAnthropic(
api_key=settings.anthropic_api_key,
default_headers=anthropic_workspace_header(settings) or None,
)
def own_key_cli_env(settings: AppSettings) -> dict[str, str]:
"""The user's own Anthropic key as the CLI's env. ANTHROPIC_CUSTOM_HEADERS is the CLI's documented
way to add a request header; a key with no workspace id gets exactly the env it always did."""
env = {"ANTHROPIC_API_KEY": settings.anthropic_api_key or ""}
header = anthropic_workspace_header(settings)
if header:
env["ANTHROPIC_CUSTOM_HEADERS"] = "\n".join(f"{name}: {value}" for name, value in header.items())
return env
def get_anthropic_client(settings: AppSettings) -> anthropic.AsyncAnthropic:
"""Return an AsyncAnthropic client for the user's current connection mode."""
import anthropic
@@ -138,7 +166,7 @@ def get_anthropic_client(settings: AppSettings) -> anthropic.AsyncAnthropic:
# Prefer the user's own API key when present.
if settings.anthropic_api_key:
return anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
return own_key_anthropic_client(settings)
# Fall back to 9Router (free for users with Claude/ChatGPT/Gemini subscriptions).
if p_check_9router():
+2
View File
@@ -118,6 +118,8 @@ class AppSettings(BaseModel):
# Off = agents can still READ your settings (redacted) but every SettingsWrite is refused.
agent_settings_write_enabled: bool = True
anthropic_api_key: Optional[str] = None
# A personal or service-account key that spans workspaces must name the one each request acts in (wrkspc_...).
anthropic_workspace_id: Optional[str] = None
browser_homepage: str = "https://www.google.com"
# Opt-in: let a blocked browser agent borrow the sign-in you already have in your everyday
# browser instead of stopping to ask you to log in again. Default OFF because reading your real
+2 -1
View File
@@ -225,7 +225,7 @@ async def apply_settings_update(body: AppSettings, protect_fields: set[str] | No
except Exception:
pass
secret_keys = {"anthropic_api_key", "openai_api_key", "google_api_key", "openrouter_api_key",
secret_keys = {"anthropic_api_key", "anthropic_workspace_id", "openai_api_key", "google_api_key", "openrouter_api_key",
"claude_subscription_token", "openai_subscription_token", "gemini_subscription_token",
"openswarm_bearer_token", "free_trial_token", "installation_id", "analytics_token"}
safe = {k: v for k, v in body.model_dump().items() if k not in secret_keys}
@@ -380,6 +380,7 @@ async def reset_system_prompt():
# A preferences reset (the iOS "Reset All Settings" analogue): everything back to defaults EXCEPT the things a "reset my preferences" click must never silently sever, your connections (server-owned subscription fields AND your pasted provider credentials) and your identity. Hard-erase is the separate flow.
P_RESET_PRESERVE_FIELDS = SERVER_OWNED_FIELDS + (
"anthropic_api_key",
"anthropic_workspace_id",
"openai_api_key",
"google_api_key",
"openrouter_api_key",
@@ -0,0 +1,74 @@
"""Anthropic's identity-linked keys (personal and service-account) can span workspaces, and such a
key must send `anthropic-workspace-id` on every request or the API answers 400 before the model is
reached. A user hit exactly that on 2026-09-03 (install 59a37510, three turns) and our card told them
to re-enter the key. The workspace id is one setting, sent from the one helper every key lane uses,
and the refusal is classified on a declared signal with its own card, first in the auth chain."""
import os
import re
from backend.apps.agents.core.error_classify import is_auth_error, is_workspace_id_error
from backend.apps.settings.credentials import anthropic_workspace_header, own_key_anthropic_client, own_key_cli_env
from backend.apps.settings.models import AppSettings
P_REAL_400 = (
'The agent runtime reported this turn failed (stop_sequence). API Error: 400 {"type":"error","error":'
'{"type":"invalid_request_error","message":"anthropic-workspace-id is required when authenticating with an '
'identity-linked API key; send the id of the workspace this request acts in."},"request_id":null}'
)
P_REAL_404 = 'API Error: 404 {"type":"error","error":{"type":"not_found_error","message":"Workspace `wrkspc_01JwQvzr7rXLA5AGx3HKfFUJ` not found."}}'
P_REAL_BAD = 'API Error: 400 {"type":"error","error":{"type":"invalid_request_error","message":"anthropic-workspace-id header must be a valid workspace ID."}}'
def test_a_key_without_a_workspace_id_gets_exactly_the_env_it_always_did():
settings = AppSettings(anthropic_api_key="sk-ant-test")
assert own_key_cli_env(settings) == {"ANTHROPIC_API_KEY": "sk-ant-test"}
assert anthropic_workspace_header(settings) == {}
def test_a_workspace_id_rides_the_cli_custom_headers_and_the_sdk_default_headers():
settings = AppSettings(anthropic_api_key="sk-ant-test", anthropic_workspace_id=" wrkspc_01JwQvzr7rXLA5AGx3HKfFUJ ")
env = own_key_cli_env(settings)
assert env["ANTHROPIC_API_KEY"] == "sk-ant-test"
assert env["ANTHROPIC_CUSTOM_HEADERS"] == "anthropic-workspace-id: wrkspc_01JwQvzr7rXLA5AGx3HKfFUJ"
client = own_key_anthropic_client(settings)
assert client.default_headers["anthropic-workspace-id"] == "wrkspc_01JwQvzr7rXLA5AGx3HKfFUJ"
def test_anthropics_three_workspace_refusals_are_classified_as_auth_on_a_declared_signal():
for text in (P_REAL_400, P_REAL_404, P_REAL_BAD):
assert is_workspace_id_error(RuntimeError(text)), text
assert is_auth_error(RuntimeError(text)), text
# Controls: an ordinary 400, a stray "workspace" and a schema 400 stay what they were.
for text in ("line 400, in run", "the workspace is on the left", 'API Error: 400 {"message":"tools.0.input_schema: unsupported"}'):
assert not is_workspace_id_error(RuntimeError(text)), text
def test_the_workspace_card_is_decided_before_every_other_auth_reading():
src = open(os.path.join(os.path.dirname(__file__), "..", "apps", "agents", "manager", "run", "handle_run_error.py")).read()
workspace = src.index("is_workspace_id_error(e, extra_text=p_stderr_tail)")
assert workspace < src.index('reason = "codex_token_rotating"')
assert workspace < src.index('reason = "anthropic_auth_invalid"')
assert 'reason = "anthropic_workspace_id"' in src
def test_no_lane_builds_a_client_or_env_from_the_raw_key_outside_credentials():
"""The class seal: the user's own key reaches the wire through own_key_* only, so a lane added
tomorrow cannot forget the workspace header the way three lanes had until 2026-09-03."""
root = os.path.join(os.path.dirname(__file__), "..", "apps")
raw_client = re.compile(r"AsyncAnthropic\(\s*api_key=(?:global_)?settings\.anthropic_api_key")
raw_env = re.compile(r'"ANTHROPIC_API_KEY":\s*(?:global_)?settings\.anthropic_api_key')
offenders = []
for dirpath, _dirs, files in os.walk(root):
for name in files:
if not name.endswith(".py"):
continue
path = os.path.join(dirpath, name)
text = open(path, encoding="utf-8", errors="replace").read()
if path.endswith(os.path.join("settings", "credentials.py")):
continue
if raw_client.search(text) or raw_env.search(text):
offenders.append(os.path.relpath(path, root))
assert offenders == [], offenders
proxy = open(os.path.join(root, "agents", "proxy", "anthropic_proxy.py")).read()
assert "anthropic_workspace_header" in proxy
@@ -1874,13 +1874,15 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
{session.context_overflow && (() => {
const reason = session.context_overflow.reason;
const isAuth = reason === 'openswarm_pro_auth_expired' || reason === 'anthropic_auth_invalid' || reason === 'auth_error';
const isWorkspace = reason === 'anthropic_workspace_id';
const isOutOfTokens = reason === 'out_of_tokens';
const isFreeTrial = reason === 'free_trial_exhausted';
const isOutOfCredits = reason === 'out_of_credits';
const opensSettings = isAuth || isFreeTrial || isOutOfCredits;
const opensSettings = isAuth || isWorkspace || isFreeTrial || isOutOfCredits;
const title = isOutOfTokens ? 'Out of tokens'
: isFreeTrial ? 'Free runs used up'
: isOutOfCredits ? 'Out of credits'
: isWorkspace ? 'Workspace ID needed'
: isAuth ? 'Sign-in required' : 'Context full';
const primaryLabel = isOutOfTokens ? 'Got it'
: isFreeTrial ? 'Connect a model'
@@ -77,6 +77,22 @@ const ApiKeyCard: React.FC<{
Get key <OpenInNewIcon sx={{ fontSize: 11 }} />
</Typography>
</Box>
{config.field === 'anthropic_api_key' && value ? (
<Box sx={{ mt: 1 }}>
<TextField
value={form.anthropic_workspace_id ?? ''}
onChange={(e) => setForm({ ...form, anthropic_workspace_id: e.target.value.trim() || null })}
size="small"
fullWidth
placeholder="Workspace ID (wrkspc_...)"
inputProps={{ 'aria-label': 'Anthropic workspace ID' }}
sx={{ ...fieldSx, '& .MuiOutlinedInput-root': { ...fieldSx['& .MuiOutlinedInput-root'], fontFamily: c.font.mono } }}
/>
<Typography sx={{ ...descSx, mt: 0.5 }}>
Only for a key that can act in more than one workspace. Find the ID under Settings, Workspaces in the Claude Console.
</Typography>
</Box>
) : null}
</Box>
);
};
@@ -0,0 +1,28 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
// Anthropic's identity-linked keys can span workspaces and then need a workspace id on every
// request. The field lives on the Anthropic card only, appears once a key is present, and the
// chat card for that refusal opens Settings under its own title instead of "Sign-in required".
const card = fs.readFileSync(path.join(process.cwd(), 'src/app/pages/Settings/sections/models/ApiKeyCard.tsx'), 'utf8');
const chat = fs.readFileSync(path.join(process.cwd(), 'src/app/pages/AgentChat/AgentChat.tsx'), 'utf8');
const slice = fs.readFileSync(path.join(process.cwd(), 'src/shared/state/settingsSlice.ts'), 'utf8');
test('the workspace id field is gated on the Anthropic card with a key present', () => {
assert.match(card, /config\.field === 'anthropic_api_key' && value \? \(/);
assert.match(card, /anthropic_workspace_id: e\.target\.value\.trim\(\) \|\| null/);
});
test('the settings shape carries the field with a null default', () => {
assert.match(slice, /anthropic_workspace_id: string \| null;/);
assert.match(slice, /anthropic_workspace_id: null,/);
});
test('the workspace refusal opens Settings under its own title', () => {
assert.match(chat, /const isWorkspace = reason === 'anthropic_workspace_id';/);
assert.match(chat, /opensSettings = isAuth \|\| isWorkspace/);
assert.match(chat, /isWorkspace \? 'Workspace ID needed'/);
});
@@ -41,6 +41,7 @@ export interface AppSettings {
dictation_sound_volume?: number;
dictation_disabled_surfaces?: string;
anthropic_api_key: string | null;
anthropic_workspace_id: string | null;
openai_api_key?: string | null;
google_api_key?: string | null;
openrouter_api_key?: string | null;
@@ -185,6 +186,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
dictation_sound_volume: 0.7,
dictation_disabled_surfaces: '',
anthropic_api_key: null,
anthropic_workspace_id: null,
browser_homepage: 'https://duckduckgo.com',
browser_import_signins: false,
auto_select_mode_on_new_agent: false,