-
Notifications
You must be signed in to change notification settings - Fork 364
Add Anthropic Administrator plugin #667
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9564221
dcfb115
8f15ec8
0b54e7c
4e8016c
4e49465
7436016
14d68a8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| import { | ||
| ApiKeySchema, | ||
| InviteSchema, | ||
| OrganizationSchema, | ||
| UserSchema, | ||
| WorkspaceSchema, | ||
| } from './endpoints/types'; | ||
| import type { AnthropicAdministratorContext } from './index'; | ||
|
|
||
| /** | ||
| * Live suite against api.anthropic.com. Requires an Admin API key | ||
| * (`sk-ant-admin…`) — a standard API key is rejected by these endpoints. | ||
| * Excluded from CI by path; enable with: | ||
| * | ||
| * ANTHROPIC_ADMIN_API_KEY=sk-ant-admin-… LIVE_TEST=1 pnpm test | ||
| * | ||
| * Read-only operations only: this suite never creates, updates, archives or | ||
| * deletes anything in a real organization. | ||
| */ | ||
| const ADMIN_KEY = process.env.ANTHROPIC_ADMIN_API_KEY; | ||
| const LIVE = process.env.LIVE_TEST === '1' || process.env.LIVE_TEST === 'true'; | ||
|
|
||
| type Ops = Record< | ||
| string, | ||
| Record< | ||
| string, | ||
| ( | ||
| c: AnthropicAdministratorContext, | ||
| i: Record<string, unknown>, | ||
| ) => Promise<unknown> | ||
| > | ||
| >; | ||
|
|
||
| let ops: Ops; | ||
|
|
||
| function op(group: string, name: string) { | ||
| const fn = ops[group]?.[name]; | ||
| if (!fn) throw new Error(`missing endpoint ${group}.${name}`); | ||
| return fn; | ||
| } | ||
|
|
||
| function ctx(key = ADMIN_KEY): AnthropicAdministratorContext { | ||
| return { | ||
| key, | ||
| options: {}, | ||
| db: {}, | ||
| } as unknown as AnthropicAdministratorContext; | ||
| } | ||
|
|
||
| const suite = ADMIN_KEY && LIVE ? describe : describe.skip; | ||
|
|
||
| suite('Anthropic Admin API (live)', () => { | ||
| beforeAll(async () => { | ||
| const mod = await import('./index'); | ||
| ops = mod.anthropicAdministratorEndpointsNested as unknown as Ops; | ||
| }); | ||
|
|
||
| it('getOrganization returns the organization for the key', async () => { | ||
| const org = await op('organization', 'getOrganization')(ctx(), {}); | ||
| expect(() => OrganizationSchema.parse(org)).not.toThrow(); | ||
| }); | ||
|
|
||
| it('listUsers returns members matching the documented shape', async () => { | ||
| const res = (await op('users', 'listUsers')(ctx(), { limit: 5 })) as { | ||
| data: unknown[]; | ||
| has_more: boolean; | ||
| }; | ||
| expect(typeof res.has_more).toBe('boolean'); | ||
| for (const user of res.data) { | ||
| expect(() => UserSchema.parse(user)).not.toThrow(); | ||
| } | ||
| }); | ||
|
|
||
| it('listInvites returns invites matching the documented shape', async () => { | ||
| const res = (await op('invites', 'listInvites')(ctx(), { limit: 5 })) as { | ||
| data: unknown[]; | ||
| }; | ||
| for (const invite of res.data) { | ||
| expect(() => InviteSchema.parse(invite)).not.toThrow(); | ||
| } | ||
| }); | ||
|
|
||
| it('listWorkspaces returns workspaces matching the documented shape', async () => { | ||
| const res = (await op('workspaces', 'listWorkspaces')(ctx(), { | ||
| limit: 5, | ||
| })) as { data: unknown[] }; | ||
| for (const workspace of res.data) { | ||
| expect(() => WorkspaceSchema.parse(workspace)).not.toThrow(); | ||
| } | ||
| }); | ||
|
|
||
| it('listApiKeys returns API keys matching the documented shape', async () => { | ||
| const res = (await op('apiKeys', 'listApiKeys')(ctx(), { limit: 5 })) as { | ||
| data: unknown[]; | ||
| }; | ||
| for (const key of res.data) { | ||
| expect(() => ApiKeySchema.parse(key)).not.toThrow(); | ||
| } | ||
| }); | ||
|
|
||
| it('honours cursor pagination on listUsers', async () => { | ||
| const page = (await op('users', 'listUsers')(ctx(), { limit: 1 })) as { | ||
| data: unknown[]; | ||
| last_id: string | null; | ||
| has_more: boolean; | ||
| }; | ||
| expect(page.data.length).toBeLessThanOrEqual(1); | ||
|
|
||
| if (page.has_more && page.last_id) { | ||
| const next = (await op('users', 'listUsers')(ctx(), { | ||
| limit: 1, | ||
| after_id: page.last_id, | ||
| })) as { data: unknown[] }; | ||
| expect(next.data).not.toEqual(page.data); | ||
| } | ||
| }); | ||
|
|
||
| it('rejects a non-admin key', async () => { | ||
| await expect( | ||
| op('users', 'listUsers')(ctx('sk-ant-not-an-admin-key'), {}), | ||
| ).rejects.toThrow(); | ||
| }); | ||
| }); | ||
|
|
||
| /** | ||
| * Reachability check that needs no credentials: a bogus key must produce a 401 | ||
| * `authentication_error` from Anthropic. A 404 or a network error would mean | ||
| * the base URL, path or auth header name is wrong. Enable with LIVE_TEST=1. | ||
| */ | ||
| const reachability = LIVE ? describe : describe.skip; | ||
|
|
||
| reachability('Anthropic Admin API reachability (no key required)', () => { | ||
| beforeAll(async () => { | ||
| const mod = await import('./index'); | ||
| ops = mod.anthropicAdministratorEndpointsNested as unknown as Ops; | ||
| }); | ||
|
|
||
| it.each([ | ||
| ['organization', 'getOrganization', {}], | ||
| ['users', 'listUsers', { limit: 1 }], | ||
| ['workspaces', 'listWorkspaces', { limit: 1 }], | ||
| ['apiKeys', 'listApiKeys', { limit: 1 }], | ||
| ] as const)( | ||
| '%s.%s resolves to a real endpoint', | ||
| async (group, name, input) => { | ||
| const error = (await op(group, name)( | ||
| ctx('sk-ant-admin-not-a-real-key'), | ||
| input as Record<string, unknown>, | ||
| ).catch((e: unknown) => e)) as { status?: number; errorType?: string }; | ||
|
|
||
| expect(error.status).toBe(401); | ||
| expect(error.errorType).toBe('authentication_error'); | ||
| }, | ||
| ); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,215 @@ | ||
| import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http'; | ||
| import { ApiError, request } from 'corsair/http'; | ||
|
|
||
| export type AnthropicAdministratorMethod = 'GET' | 'POST' | 'DELETE'; | ||
|
|
||
| /** | ||
| * Error thrown by every Admin API call. | ||
| * | ||
| * The transport status and rate-limit metadata are copied off the underlying | ||
| * `ApiError` so `error-handlers.ts` can match on them — a wrapper that only | ||
| * carried `message` would make the 429 policy unreachable, because corsair | ||
| * throws a 429 with the message "Too Many Requests" (no status in the text). | ||
| */ | ||
| export class AnthropicAdministratorAPIError extends Error { | ||
| public readonly status?: number; | ||
| public readonly statusText?: string; | ||
| /** Admin API error bodies are `{ type: "error", error: { type, message } }`. */ | ||
| public readonly body?: unknown; | ||
| public readonly retryAfter?: number; | ||
| /** HTTP method of the failed request, so retries can tell reads from writes. */ | ||
| public readonly method?: AnthropicAdministratorMethod; | ||
| /** Anthropic error type, e.g. `authentication_error`, `not_found_error`. */ | ||
| public readonly errorType?: string; | ||
|
|
||
| constructor( | ||
| message: string, | ||
| options?: { cause?: Error; method?: AnthropicAdministratorMethod }, | ||
| ) { | ||
| super(message, options); | ||
| this.name = 'AnthropicAdministratorAPIError'; | ||
| this.method = options?.method; | ||
|
|
||
| const cause = options?.cause; | ||
| if (cause instanceof ApiError) { | ||
| this.status = cause.status; | ||
| this.statusText = cause.statusText; | ||
| this.body = cause.body; | ||
| this.retryAfter = cause.retryAfter; | ||
| this.errorType = readErrorType(cause.body); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** Pulls `error.type` out of an Anthropic error envelope when present. */ | ||
| function readErrorType(body: unknown): string | undefined { | ||
| if (typeof body !== 'object' || body === null) return undefined; | ||
| const error = (body as { error?: unknown }).error; | ||
| if (typeof error !== 'object' || error === null) return undefined; | ||
| const type = (error as { type?: unknown }).type; | ||
| return typeof type === 'string' ? type : undefined; | ||
| } | ||
|
|
||
| const ANTHROPIC_API_BASE = 'https://api.anthropic.com'; | ||
|
|
||
| /** | ||
| * Version header required on every request to the Anthropic API. | ||
| * https://platform.claude.com/docs/en/api/versioning | ||
| */ | ||
| const ANTHROPIC_VERSION = '2023-06-01'; | ||
|
|
||
| /** | ||
| * Which credential `apiKey` holds. Admin API keys authenticate with `x-api-key`; | ||
| * OAuth tokens carrying the `org:admin` scope use `authorization: Bearer`. | ||
| * https://platform.claude.com/docs/en/manage-claude/admin-api | ||
| */ | ||
| export type AnthropicAdministratorAuthType = 'api_key' | 'oauth_2'; | ||
|
|
||
| export type AnthropicAdministratorRequestOptions = { | ||
| method?: AnthropicAdministratorMethod; | ||
| authType?: AnthropicAdministratorAuthType; | ||
| /** Request payloads differ per operation; validated by per-op zod schemas. */ | ||
| body?: Record<string, unknown>; | ||
| /** | ||
| * Query values are heterogeneous across the Admin API (cursors, limits, | ||
| * repeated filters), so arrays are allowed for repeatable params. | ||
| */ | ||
| query?: Record<string, string | number | boolean | string[] | undefined>; | ||
| }; | ||
|
|
||
| /** | ||
| * Performs a request against the Anthropic Admin API. | ||
| * | ||
| * Auth: an Admin API key (`sk-ant-admin…`) in the `x-api-key` header, or an | ||
| * OAuth token with the `org:admin` scope in `authorization: Bearer`. Admin keys | ||
| * are provisioned by organization admins and are distinct from standard API | ||
| * keys. | ||
| */ | ||
| /** | ||
| * Upper bound on a request path. | ||
| * | ||
| * Every path this plugin builds is a short literal prefix (at most | ||
| * `/v1/organizations/workspaces`) plus one or two percent-encoded resource | ||
| * IDs, so this is far above anything legitimate. | ||
| * | ||
| * It also bounds the work done by the `{placeholder}` substitution in | ||
| * `corsair/http`, whose regex is polynomial in the number of unmatched `{` | ||
| * characters (CodeQL `js/polynomial-redos`). Capping the input length is the | ||
| * documented mitigation when the regex itself is not owned here. | ||
| */ | ||
| const MAX_ENDPOINT_LENGTH = 512; | ||
|
|
||
| /** Total attempts for a retryable failure (1 initial + 2 retries). */ | ||
| const MAX_ATTEMPTS = 3; | ||
|
|
||
| /** Upper bound on an honoured `Retry-After`, so a hostile header cannot stall a caller. */ | ||
| const MAX_RETRY_DELAY_MS = 30_000; | ||
|
|
||
| const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); | ||
|
|
||
| /** | ||
| * Whether a failed attempt may be replayed. | ||
| * | ||
| * A 429 is safe for any method: the request was rejected before being applied. | ||
| * A 5xx may have been applied server-side, and the Admin API documents no | ||
| * idempotency key, so only GET is replayed. | ||
| */ | ||
| function isRetryable( | ||
| status: number | undefined, | ||
| method: AnthropicAdministratorMethod, | ||
| ): boolean { | ||
| if (status === 429) return true; | ||
| if (status !== undefined && status >= 500) return method === 'GET'; | ||
| return false; | ||
| } | ||
|
|
||
| function retryDelayMs(error: ApiError, attempt: number): number { | ||
| const retryAfter = error.retryAfter; | ||
| if (typeof retryAfter === 'number' && retryAfter > 0) { | ||
| return Math.min(retryAfter, MAX_RETRY_DELAY_MS); | ||
| } | ||
| return Math.min(2 ** (attempt - 1) * 1000, MAX_RETRY_DELAY_MS); | ||
| } | ||
|
|
||
| /** | ||
| * Performs a request against the Anthropic Admin API. | ||
| * | ||
| * Auth: an Admin API key (`sk-ant-admin…`) in the `x-api-key` header, or an | ||
| * OAuth token with the `org:admin` scope in `authorization: Bearer`. Admin keys | ||
| * are provisioned by organization admins and are distinct from standard API | ||
| * keys. | ||
| * | ||
| * Retries are performed here rather than delegated to the shared endpoint | ||
| * binder, so a request that succeeds on retry returns that result to the | ||
| * caller instead of surfacing the first failure. | ||
| */ | ||
| export async function makeAnthropicAdministratorRequest<T>( | ||
| endpoint: string, | ||
| apiKey: string, | ||
| options: AnthropicAdministratorRequestOptions = {}, | ||
| ): Promise<T> { | ||
| const { method = 'GET', body, query, authType = 'api_key' } = options; | ||
|
|
||
| if (endpoint.length > MAX_ENDPOINT_LENGTH) { | ||
| throw new AnthropicAdministratorAPIError( | ||
| `Request path exceeds ${MAX_ENDPOINT_LENGTH} characters`, | ||
| { method }, | ||
| ); | ||
| } | ||
|
|
||
| const isWrite = method === 'POST'; | ||
|
|
||
| const credential: Record<string, string> = | ||
| authType === 'oauth_2' | ||
| ? { authorization: `Bearer ${apiKey}` } | ||
| : { 'x-api-key': apiKey }; | ||
|
|
||
| const config: OpenAPIConfig = { | ||
| BASE: ANTHROPIC_API_BASE, | ||
| VERSION: ANTHROPIC_VERSION, | ||
| WITH_CREDENTIALS: false, | ||
| CREDENTIALS: 'omit', | ||
| TOKEN: undefined, | ||
| HEADERS: { | ||
| ...credential, | ||
| 'anthropic-version': ANTHROPIC_VERSION, | ||
| ...(isWrite ? { 'content-type': 'application/json' } : {}), | ||
| }, | ||
| }; | ||
|
|
||
| const requestOptions: ApiRequestOptions = { | ||
| method, | ||
| url: endpoint, | ||
| body: isWrite ? body : undefined, | ||
| mediaType: isWrite ? 'application/json' : undefined, | ||
| query, | ||
| }; | ||
|
|
||
| let lastError: unknown; | ||
|
|
||
| for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { | ||
| try { | ||
| return await request<T>(config, requestOptions); | ||
| } catch (error) { | ||
| lastError = error; | ||
|
|
||
| const status = error instanceof ApiError ? error.status : undefined; | ||
| const canRetry = | ||
| error instanceof ApiError && | ||
| attempt < MAX_ATTEMPTS && | ||
| isRetryable(status, method); | ||
|
|
||
| if (!canRetry) break; | ||
|
|
||
| await sleep(retryDelayMs(error as ApiError, attempt)); | ||
| } | ||
| } | ||
|
Comment on lines
+188
to
+206
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
rg -n -C 10 'DEFAULT_RATE_LIMIT_CONFIG' packages/corsair
rg -n -C 6 'interface RateLimitConfig|type RateLimitConfig' packages/corsairRepository: corsairdev/corsair Length of output: 8459 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- client imports and retry loop ---'
sed -n '1,230p' packages/anthropicadministrator/client.ts
printf '%s\n' '--- shared request retry implementation ---'
sed -n '320,430p' packages/corsair/async-core/request.ts
printf '%s\n' '--- rate-limit implementation ---'
cat -n packages/corsair/async-core/rate-limit.ts
printf '%s\n' '--- relevant symbols and call sites ---'
rg -n -C 5 'MAX_ATTEMPTS|retryDelayMs|isRetryable|request<|rateLimitConfig' packages/anthropicadministrator packages/corsair/async-coreRepository: corsairdev/corsair Length of output: 21056 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- complete shared retry catch path ---'
sed -n '390,455p' packages/corsair/async-core/request.ts
printf '%s\n' '--- ApiError rate-limit behavior ---'
rg -n -C 12 'class ApiError|isRateLimitError|retryAfter' packages/corsair/async-core/ApiError.ts packages/corsair/async-core
printf '%s\n' '--- public exports for retry configuration ---'
rg -n -C 5 'DEFAULT_RATE_LIMIT_CONFIG|RequestOptions|rate-limit' packages/corsair --glob '*.{ts,tsx}'Repository: corsairdev/corsair Length of output: 50376 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
default_max_retries = 3
outer_max_attempts = 3
shared_attempts = default_max_retries + 1
print({
"shared_attempts_per_outer_attempt": shared_attempts,
"outer_attempts": outer_max_attempts,
"maximum_network_requests_for_sustained_429": shared_attempts * outer_max_attempts,
"shared_backoff_ms_without_retry_after": [1000, 2000, 4000],
"outer_backoff_ms_without_retry_after": [1000, 2000],
})
PY
printf '%s\n' '--- public corsair/http exports ---'
sed -n '12,25p' packages/corsair/http.tsRepository: corsairdev/corsair Length of output: 952 Disable shared rate-limit retries for this request.
🤖 Prompt for AI Agents |
||
|
|
||
| if (lastError instanceof Error) { | ||
| throw new AnthropicAdministratorAPIError(lastError.message, { | ||
| cause: lastError, | ||
| method, | ||
| }); | ||
| } | ||
| throw new AnthropicAdministratorAPIError('Unknown error', { method }); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.