anthropicViaProxy — the boundary for platform-side Claude calls
The proxy secret lives in sidecar/claude-state.json: platform-internal state whose location, format and permissions are this repo's to change. The first caller outside the sidecar read the file directly, which worked and meant the sidecar could no longer move its own state without breaking a plugin. Plugins import this module now, and only platform code touches the file. A module and not an HTTP route, deliberately: every caller is server-side code in this process, already behind its own gate. An endpoint that spends the owner's subscription would be new surface — reachable by anything with a session, needing its own registry entry — bought for no caller that exists. The day a browser-side caller appears, the route is a thin wrapper over this. The SDK is re-exported from here too, because a plugin resolving its own copy from the plugins' node_modules would have its own class identities — and an error thrown by this copy is not an instanceof the other's RateLimitError, so error mapping silently stops matching. Deliberate unfreeze for @anthropic-ai/sdk ^0.71.0; the lockfile diff is that one package and its json-schema-to-ts dependency.
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import { ANTHROPIC_PROXY_URL } from './officer-url.mjs';
|
||||
import { readProxySecretFromDisk } from './sidecar/claude/state';
|
||||
|
||||
/**
|
||||
* The sanctioned way for PLATFORM-SIDE code — including a plugin's server routes — to call Claude.
|
||||
*
|
||||
* ── Why this module exists ──
|
||||
*
|
||||
* `officer-anthropic-proxy` swaps a local secret for the owner's OAuth bearer, and that secret lives
|
||||
* in `sidecar/claude-state.json` — platform-internal state whose location, format and permissions are
|
||||
* this repo's to change. The first caller outside the sidecar (fofos-website's translate route) read
|
||||
* the file directly, which worked and meant the sidecar could no longer move its own state without
|
||||
* breaking a plugin. This module is the boundary: plugins import IT, and only platform code touches
|
||||
* the file.
|
||||
*
|
||||
* ── Why a module and not an HTTP route ──
|
||||
*
|
||||
* Every caller so far is server-side code in this same process, already behind its own gate. An HTTP
|
||||
* endpoint that spends the owner's subscription would be NEW surface — reachable by anything holding a
|
||||
* session, needing its own registry entry and permission — bought for no caller that exists. The day
|
||||
* a browser-side caller appears, the route is a thin wrapper over this plus that registry entry.
|
||||
*
|
||||
* ── The secret is read per call ──
|
||||
*
|
||||
* A fresh install writes it through a 30s debounce, so anything that cached at boot could hold an
|
||||
* empty string forever. Reading late costs one small file read and removes the failure mode.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A client pointed at the proxy, or null when the secret is not on disk yet.
|
||||
*
|
||||
* Null rather than a throw, because "not yet" is an ordinary state on a fresh install — the caller
|
||||
* says which process is missing, in its own words, with its own status code.
|
||||
*/
|
||||
/*
|
||||
Re-exported so a caller's `instanceof` works.
|
||||
|
||||
A plugin that imported '@anthropic-ai/sdk' itself could resolve a SECOND copy from the plugins'
|
||||
own node_modules — and an error thrown by the platform's copy is not an instance of the other
|
||||
one's classes, so `catch (e) { e instanceof RateLimitError }` silently stops matching. One import
|
||||
path, one copy, one class identity.
|
||||
*/
|
||||
export { default as Anthropic } from '@anthropic-ai/sdk';
|
||||
|
||||
export function anthropicViaProxy(): Anthropic | null {
|
||||
const secret = readProxySecretFromDisk();
|
||||
if (!secret) return null;
|
||||
return new Anthropic({ apiKey: secret, baseURL: ANTHROPIC_PROXY_URL });
|
||||
}
|
||||
Reference in New Issue
Block a user