@steve31415/baselib 3.14.0 → 3.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -6
- package/dist/activity.d.ts +5 -3
- package/dist/activity.js +13 -6
- package/dist/auth.d.ts +2 -2
- package/dist/auth.js +1 -1
- package/dist/db.d.ts +4 -4
- package/dist/db.js +7 -7
- package/dist/deploy/plan.d.ts +1 -1
- package/dist/deploy/plan.js +1 -1
- package/dist/deploy/types.d.ts +3 -3
- package/dist/http.js +3 -2
- package/dist/llm/complete.js +2 -1
- package/dist/llm/models.d.ts +17 -0
- package/dist/llm/models.js +18 -5
- package/dist/llm/providers/anthropic.js +68 -4
- package/dist/llm/types.d.ts +7 -0
- package/dist/log-core.d.ts +4 -0
- package/dist/log-core.js +6 -1
- package/dist/log.d.ts +1 -1
- package/dist/log.js +3 -2
- package/dist/process-meta.d.ts +3 -0
- package/dist/process-meta.js +16 -0
- package/dist/rum.js +1 -1
- package/dist/s2s.js +1 -1
- package/dist/twitter-client.d.ts +1 -1
- package/dist/twitter-client.js +1 -1
- package/dist/twitter-mirror.js +2 -2
- package/dist/twitter.js +3 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# baselib
|
|
2
2
|
|
|
3
|
-
The Plasticine
|
|
3
|
+
The Plasticine fleet's shared platform library (`@steve31415/baselib`).
|
|
4
4
|
What it provides and why: `docs/SPEC.md`. How it's put together:
|
|
5
5
|
`docs/IMPL.md`. Full design rationale with alternatives:
|
|
6
|
-
`~/migration/research/base-services-design.md
|
|
6
|
+
`~/migration/research/base-services-design.md`.
|
|
7
7
|
|
|
8
8
|
Server subpath exports: `config`, `log`, `auth`, `s2s`, `db`, `http`, `sync`,
|
|
9
9
|
`app-update`, `llm`, `email`, and `twitter`. Browser exports: `log-browser`, `rum`,
|
|
@@ -34,17 +34,15 @@ deploy (`~/plasticine-way/docs/OPERATIONS.md`, "Deployment safety").
|
|
|
34
34
|
first step of any production investigation (same doc, "Investigating
|
|
35
35
|
production state").
|
|
36
36
|
|
|
37
|
-
`twitter` provides
|
|
37
|
+
`twitter` provides Mirror-first tweet lookup, TwitterAPI.io batch/search/
|
|
38
38
|
article calls, and thread assembly through direct reply edges. It includes
|
|
39
39
|
one level of quotes, article bodies and URL expansions. Use one shared
|
|
40
40
|
`MirrorTweetClient(mirrorUrl, serviceFetch)` and `TwitterApiIoClient(key)`
|
|
41
41
|
per run; `assembleThread(id, { api, mirror, ancestors: 'all' })` returns
|
|
42
42
|
role-tagged tweets, source counts and warnings. Batch prefetching Mirror
|
|
43
|
-
ids seeds the shared cache.
|
|
43
|
+
ids seeds the shared cache. Reader and Snip retain their copies for now.
|
|
44
44
|
|
|
45
45
|
Releasing: bump `version`, commit, and run `npm run deploy` (Coder runs it
|
|
46
46
|
automatically when integrating a baselib branch) — it pushes the `v<version>`
|
|
47
47
|
tag, whose workflow gates on verify and publishes via OIDC, and waits for the
|
|
48
48
|
registry (`~/plasticine-way/docs/OPERATIONS.md`, "Publishing baselib").
|
|
49
|
-
|
|
50
|
-
Reflects Plasticine Way commit: see `docs/IMPL.md` footer.
|
package/dist/activity.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
export declare function requestStarted(): void;
|
|
2
2
|
export declare function requestEnded(): void;
|
|
3
|
-
/** The fields
|
|
4
|
-
*
|
|
3
|
+
/** The fields an event carries: requests in flight right now and how long
|
|
4
|
+
* ago the last one ended. Before the first request has ended there is no
|
|
5
|
+
* such moment, so `sinceLastRequestMs` is omitted rather than invented;
|
|
6
|
+
* `inFlightRequests` alone marks a server that has not finished one yet. */
|
|
5
7
|
export declare function activitySnapshot(): {
|
|
6
8
|
inFlightRequests: number;
|
|
7
9
|
sinceLastRequestMs?: number;
|
|
@@ -10,5 +12,5 @@ export declare function activitySnapshot(): {
|
|
|
10
12
|
* request ends when the handler returns its Response; a streaming body
|
|
11
13
|
* that outlives that is not tracked. */
|
|
12
14
|
export declare function trackActivity<A extends unknown[]>(fetch: (...args: A) => Response | Promise<Response>): (...args: A) => Promise<Response>;
|
|
13
|
-
/** Test hook: forget everything. */
|
|
15
|
+
/** Test hook: forget everything, including the logger registration. */
|
|
14
16
|
export declare function resetActivity(): void;
|
package/dist/activity.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Request activity of this process,
|
|
2
|
-
// whether Cloud Run had CPU allocated at the time: with request-based
|
|
1
|
+
// Request activity of this process, carried by every server log event so
|
|
2
|
+
// any of them can say whether Cloud Run had CPU allocated at the time: with request-based
|
|
3
3
|
// billing (the fleet default) an instance's CPU is throttled to almost
|
|
4
4
|
// nothing between requests, and timers, connection handshakes and
|
|
5
5
|
// credential refreshes crawl. A connection failure logged with
|
|
@@ -7,10 +7,13 @@
|
|
|
7
7
|
// throttled tail; one with `inFlightRequests: 1` did not.
|
|
8
8
|
//
|
|
9
9
|
// `startServer` tracks every HTTP request automatically. A custom server
|
|
10
|
-
// wraps its fetch handler with `trackActivity`.
|
|
10
|
+
// wraps its fetch handler with `trackActivity`. Wrapping is also what
|
|
11
|
+
// registers this module as the logger's process-meta source (process-meta.ts), so a
|
|
12
|
+
// process that serves nothing logs no activity fields. WebSocket sessions are not
|
|
11
13
|
// counted (they hold CPU on Cloud Run, but the upgrade bypasses fetch), so
|
|
12
14
|
// on a WebSocket-heavy app zero in-flight requests does not prove the
|
|
13
15
|
// instance was idle.
|
|
16
|
+
import { setProcessMetaSource } from './process-meta.js';
|
|
14
17
|
let inFlight = 0;
|
|
15
18
|
let lastRequestEndedAt = 0;
|
|
16
19
|
export function requestStarted() {
|
|
@@ -20,8 +23,10 @@ export function requestEnded() {
|
|
|
20
23
|
inFlight = Math.max(0, inFlight - 1);
|
|
21
24
|
lastRequestEndedAt = Date.now();
|
|
22
25
|
}
|
|
23
|
-
/** The fields
|
|
24
|
-
*
|
|
26
|
+
/** The fields an event carries: requests in flight right now and how long
|
|
27
|
+
* ago the last one ended. Before the first request has ended there is no
|
|
28
|
+
* such moment, so `sinceLastRequestMs` is omitted rather than invented;
|
|
29
|
+
* `inFlightRequests` alone marks a server that has not finished one yet. */
|
|
25
30
|
export function activitySnapshot() {
|
|
26
31
|
return {
|
|
27
32
|
inFlightRequests: inFlight,
|
|
@@ -32,6 +37,7 @@ export function activitySnapshot() {
|
|
|
32
37
|
* request ends when the handler returns its Response; a streaming body
|
|
33
38
|
* that outlives that is not tracked. */
|
|
34
39
|
export function trackActivity(fetch) {
|
|
40
|
+
setProcessMetaSource(activitySnapshot);
|
|
35
41
|
return async (...args) => {
|
|
36
42
|
requestStarted();
|
|
37
43
|
try {
|
|
@@ -42,8 +48,9 @@ export function trackActivity(fetch) {
|
|
|
42
48
|
}
|
|
43
49
|
};
|
|
44
50
|
}
|
|
45
|
-
/** Test hook: forget everything. */
|
|
51
|
+
/** Test hook: forget everything, including the logger registration. */
|
|
46
52
|
export function resetActivity() {
|
|
53
|
+
setProcessMetaSource(undefined);
|
|
47
54
|
inFlight = 0;
|
|
48
55
|
lastRequestEndedAt = 0;
|
|
49
56
|
}
|
package/dist/auth.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export declare const SESSION_COOKIE = "pw_session";
|
|
|
6
6
|
/** The `code` in a 403 JSON body when a signed-in user is refused by the
|
|
7
7
|
* app's userAccess policy (as opposed to any other 403). */
|
|
8
8
|
export declare const ACCESS_DENIED_CODE = "app_access_denied";
|
|
9
|
-
/** A user's fleet role, assigned in
|
|
9
|
+
/** A user's fleet role, assigned in auth's users table. */
|
|
10
10
|
export type UserRole = 'owner' | 'invited';
|
|
11
11
|
/** Which signed-in users an app admits. 'owner' (the default): only the
|
|
12
12
|
* fleet owner(s). 'invited': any active fleet user. */
|
|
@@ -39,7 +39,7 @@ export interface RequireIdentityOptions extends S2sOptions {
|
|
|
39
39
|
missMs?: number;
|
|
40
40
|
};
|
|
41
41
|
/** Which signed-in users this app admits. Default 'owner': only users
|
|
42
|
-
* whose
|
|
42
|
+
* whose auth role is owner. Pass 'invited' only for an app that is
|
|
43
43
|
* multi-user by design, and say so in its SPEC. Applies to browser
|
|
44
44
|
* sessions and the test bypass alike; service callers are governed by
|
|
45
45
|
* grants, not by this. */
|
package/dist/auth.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// in-instance (10 min hits / 30 s misses — the old-world numbers).
|
|
13
13
|
//
|
|
14
14
|
// Then the user-access policy: by default an app admits only users whose
|
|
15
|
-
//
|
|
15
|
+
// auth role is 'owner'; `userAccess: 'invited'` admits any active fleet
|
|
16
16
|
// user. Being on the fleet sign-in allowlist never grants access by itself,
|
|
17
17
|
// so a port that forgets the question is closed, not open. Service callers
|
|
18
18
|
// are governed by grants, not by this policy.
|
package/dist/db.d.ts
CHANGED
|
@@ -100,9 +100,9 @@ export declare const FLEET_POOL_DEFAULTS: {
|
|
|
100
100
|
* any transaction or advisory lock it held is gone.
|
|
101
101
|
* Each event carries `elapsedMs` (time in that phase: the connect
|
|
102
102
|
* attempt, the wait, the idle spell, the checkout), the pg `code`, the
|
|
103
|
-
* pool's counts,
|
|
104
|
-
*
|
|
105
|
-
* `
|
|
103
|
+
* pool's counts and, given a Cloud SQL credential reader,
|
|
104
|
+
* `credentialRemainingMs`; the logger adds request activity, as on every
|
|
105
|
+
* server event (`activity.ts`: was Cloud Run CPU allocated?). Query:
|
|
106
106
|
* `['apps'] | where kind == 'db-connection-loss' | summarize count() by app, tostring(meta.phase)`.
|
|
107
107
|
*
|
|
108
108
|
* The checked-out path is the one that used to crash the process: pg-pool
|
|
@@ -111,7 +111,7 @@ export declare const FLEET_POOL_DEFAULTS: {
|
|
|
111
111
|
* rule: an advisory lock is not mutual exclusion. Our locks limit
|
|
112
112
|
* duplicated effort; code under one must be safe to overlap, and long
|
|
113
113
|
* work should probe the lock connection periodically (`select 1`) and
|
|
114
|
-
* stop on failure (Plasticine Way `docs/DATABASE.md`;
|
|
114
|
+
* stop on failure (Plasticine Way `docs/DATABASE.md`; mirror's ingestion
|
|
115
115
|
* loop is the reference).
|
|
116
116
|
*
|
|
117
117
|
* `createPool` attaches all of this; exported for a pool an app built
|
package/dist/db.js
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
import { readdir, readFile } from 'node:fs/promises';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import pg from 'pg';
|
|
8
|
-
import { activitySnapshot } from './activity.js';
|
|
9
8
|
import { requireEnv } from './config.js';
|
|
9
|
+
import { processMeta } from './process-meta.js';
|
|
10
10
|
import { serializeError } from './log-core.js';
|
|
11
11
|
// DATE columns come back as 'YYYY-MM-DD' strings, not JS Dates: calendar
|
|
12
12
|
// dates are timezone-free by meaning, and a Date object smears them across
|
|
@@ -106,9 +106,9 @@ const instrumented = new WeakSet();
|
|
|
106
106
|
* any transaction or advisory lock it held is gone.
|
|
107
107
|
* Each event carries `elapsedMs` (time in that phase: the connect
|
|
108
108
|
* attempt, the wait, the idle spell, the checkout), the pg `code`, the
|
|
109
|
-
* pool's counts,
|
|
110
|
-
*
|
|
111
|
-
* `
|
|
109
|
+
* pool's counts and, given a Cloud SQL credential reader,
|
|
110
|
+
* `credentialRemainingMs`; the logger adds request activity, as on every
|
|
111
|
+
* server event (`activity.ts`: was Cloud Run CPU allocated?). Query:
|
|
112
112
|
* `['apps'] | where kind == 'db-connection-loss' | summarize count() by app, tostring(meta.phase)`.
|
|
113
113
|
*
|
|
114
114
|
* The checked-out path is the one that used to crash the process: pg-pool
|
|
@@ -117,7 +117,7 @@ const instrumented = new WeakSet();
|
|
|
117
117
|
* rule: an advisory lock is not mutual exclusion. Our locks limit
|
|
118
118
|
* duplicated effort; code under one must be safe to overlap, and long
|
|
119
119
|
* work should probe the lock connection periodically (`select 1`) and
|
|
120
|
-
* stop on failure (Plasticine Way `docs/DATABASE.md`;
|
|
120
|
+
* stop on failure (Plasticine Way `docs/DATABASE.md`; mirror's ingestion
|
|
121
121
|
* loop is the reference).
|
|
122
122
|
*
|
|
123
123
|
* `createPool` attaches all of this; exported for a pool an app built
|
|
@@ -134,7 +134,6 @@ export function logPoolErrors(pool, logger, options = {}) {
|
|
|
134
134
|
...(since === undefined ? {} : { elapsedMs: Date.now() - since }),
|
|
135
135
|
code: err.code,
|
|
136
136
|
pool: { total: pool.totalCount, idle: pool.idleCount, waiting: pool.waitingCount },
|
|
137
|
-
...activitySnapshot(),
|
|
138
137
|
...(options.credentialExpiry
|
|
139
138
|
? expiry === undefined
|
|
140
139
|
? { credentialUnknown: true }
|
|
@@ -144,8 +143,9 @@ export function logPoolErrors(pool, logger, options = {}) {
|
|
|
144
143
|
};
|
|
145
144
|
if (log)
|
|
146
145
|
log.warn(message, meta);
|
|
146
|
+
// No logger to add the activity fields, so add them here.
|
|
147
147
|
else
|
|
148
|
-
console.warn(message, { kind: CONNECTION_LOSS_KIND, ...meta, error: serializeError(err) });
|
|
148
|
+
console.warn(message, { kind: CONNECTION_LOSS_KIND, ...processMeta(), ...meta, error: serializeError(err) });
|
|
149
149
|
};
|
|
150
150
|
// Per-client bookkeeping across one checkout: the borrower-side error
|
|
151
151
|
// listener (pg-pool drops its own at checkout) and when the client was
|
package/dist/deploy/plan.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export declare const SHA40: RegExp;
|
|
|
3
3
|
/** Lock staleness is judged from the GCS object's server-side timeCreated,
|
|
4
4
|
* never from anything the lock writer wrote (its clock may be wrong). */
|
|
5
5
|
export declare function lockIsStale(timeCreatedIso: string, nowMs: number, staleMs: number): boolean;
|
|
6
|
-
/** Consistent serving base across an app's services (
|
|
6
|
+
/** Consistent serving base across an app's services (notes's rule,
|
|
7
7
|
* generalized): all services on one SHA -> that SHA; some already on the
|
|
8
8
|
* target -> the rest must agree on one other SHA (the base); anything else
|
|
9
9
|
* is a skew that needs a human. A null buildSha (pre-unification revision)
|
package/dist/deploy/plan.js
CHANGED
|
@@ -11,7 +11,7 @@ export function lockIsStale(timeCreatedIso, nowMs, staleMs) {
|
|
|
11
11
|
return false;
|
|
12
12
|
return nowMs - created > staleMs;
|
|
13
13
|
}
|
|
14
|
-
/** Consistent serving base across an app's services (
|
|
14
|
+
/** Consistent serving base across an app's services (notes's rule,
|
|
15
15
|
* generalized): all services on one SHA -> that SHA; some already on the
|
|
16
16
|
* target -> the rest must agree on one other SHA (the base); anything else
|
|
17
17
|
* is a skew that needs a human. A null buildSha (pre-unification revision)
|
package/dist/deploy/types.d.ts
CHANGED
|
@@ -51,7 +51,7 @@ export interface DeployConfig {
|
|
|
51
51
|
* runtime-config change is a commit the deploy carries rather than a hand-run
|
|
52
52
|
* `gcloud run services update`. Undeclared vars are left as they are. */
|
|
53
53
|
updateEnv?: Record<string, string>;
|
|
54
|
-
/** Refuse to deploy across a migrations/ diff (
|
|
54
|
+
/** Refuse to deploy across a migrations/ diff (notes semantics). */
|
|
55
55
|
refuseMigrationDiffOnDeploy?: boolean;
|
|
56
56
|
/** Repo-relative path of an optional ES module exporting hooks. */
|
|
57
57
|
hooks?: string;
|
|
@@ -114,10 +114,10 @@ export interface DeployHooks {
|
|
|
114
114
|
publishAssets?: (ctx: HookContext) => Promise<void>;
|
|
115
115
|
/** Runs after all gates pass, immediately before the first traffic switch. */
|
|
116
116
|
preRelease?: (ctx: HookContext) => Promise<void>;
|
|
117
|
-
/** Runs right after traffic moves (e.g.
|
|
117
|
+
/** Runs right after traffic moves (e.g. todo's announce-build). */
|
|
118
118
|
postTraffic?: (ctx: HookContext) => Promise<void>;
|
|
119
119
|
/** Extra smoke checks after the shared health checks. */
|
|
120
120
|
smoke?: (ctx: HookContext) => Promise<void>;
|
|
121
|
-
/** Runs after everything else succeeded (e.g.
|
|
121
|
+
/** Runs after everything else succeeded (e.g. notes's release state). */
|
|
122
122
|
postSuccess?: (ctx: HookContext) => Promise<void>;
|
|
123
123
|
}
|
package/dist/http.js
CHANGED
|
@@ -55,8 +55,9 @@ export function startServer(opts) {
|
|
|
55
55
|
assertBootSafety();
|
|
56
56
|
logUncaughtErrors(opts.logger);
|
|
57
57
|
const port = opts.port ?? Number(process.env.PORT ?? 8080);
|
|
58
|
-
// Every request counts as activity (activity.ts)
|
|
59
|
-
//
|
|
58
|
+
// Every request counts as activity (activity.ts), and wrapping registers
|
|
59
|
+
// the logger's activity source: every event from here on says whether
|
|
60
|
+
// Cloud Run had CPU allocated at the time.
|
|
60
61
|
const server = serve({ fetch: trackActivity(opts.app.fetch), port }, (info) => {
|
|
61
62
|
opts.logger.info('server started', { port: info.port });
|
|
62
63
|
});
|
package/dist/llm/complete.js
CHANGED
|
@@ -66,7 +66,8 @@ export async function llmComplete(request) {
|
|
|
66
66
|
temperature,
|
|
67
67
|
supportsTemperature: modelEntry.supportsTemperature ?? true,
|
|
68
68
|
supportsThinkingBudgetZero: modelEntry.supportsThinkingBudgetZero ?? true,
|
|
69
|
-
|
|
69
|
+
supportsForcedToolChoice: modelEntry.supportsForcedToolChoice ?? true,
|
|
70
|
+
effort: modelEntry.supportsEffort ? (request.effort ?? modelEntry.defaultEffort) : undefined,
|
|
70
71
|
jsonSchema: request.jsonSchema,
|
|
71
72
|
apiKey,
|
|
72
73
|
logger: request.logger,
|
package/dist/llm/models.d.ts
CHANGED
|
@@ -40,6 +40,23 @@ export interface ModelEntry {
|
|
|
40
40
|
* when the caller's output budget leaves no meaningful headroom.
|
|
41
41
|
*/
|
|
42
42
|
thinksByDefault?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Whether this model accepts a forced `tool_choice` (`{type: 'tool'}`),
|
|
45
|
+
* which the Anthropic adapter uses to get `jsonSchema` output. Defaults to
|
|
46
|
+
* `true`. Opus 5.5 rejects it with a 400 ("tool_choice: type \"tool\" and
|
|
47
|
+
* \"any\" are not supported for this model.", verified 2026-09-22); for
|
|
48
|
+
* such models the adapter requests structured output via
|
|
49
|
+
* `output_config.format` instead.
|
|
50
|
+
*/
|
|
51
|
+
supportsForcedToolChoice?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Effort level sent when the caller passes none (effort-capable models
|
|
54
|
+
* only). Absent means "send nothing and take the provider's default".
|
|
55
|
+
* Opus 5.5's API default is `medium`, one level below Opus 5's `high`; the
|
|
56
|
+
* registry pins `high` so moving a call site from Opus 5 to Opus 5.5 does
|
|
57
|
+
* not silently lower its effort.
|
|
58
|
+
*/
|
|
59
|
+
defaultEffort?: 'low' | 'medium' | 'high';
|
|
43
60
|
}
|
|
44
61
|
/**
|
|
45
62
|
* Output budget below which a `thinksByDefault` model is considered at risk of
|
package/dist/llm/models.js
CHANGED
|
@@ -10,7 +10,7 @@ export const THINKING_HEADROOM_WARN_TOKENS = 4096;
|
|
|
10
10
|
*/
|
|
11
11
|
export const MODEL_REGISTRY = {
|
|
12
12
|
// 2026-08 suite refresh: current models are claude-sonnet-5 / claude-opus-5
|
|
13
|
-
// (Anthropic), the gpt-5.6 sol/terra/luna family (OpenAI), and the Gemini
|
|
13
|
+
// (Anthropic; claude-opus-5-5 succeeded claude-opus-5 on 2026-09-22), the gpt-5.6 sol/terra/luna family (OpenAI), and the Gemini
|
|
14
14
|
// 3.5/3.6 GA line plus gemini-3.1-pro-preview (Google). Older names stay
|
|
15
15
|
// registered so existing callers keep working, but no fallback chain routes
|
|
16
16
|
// through a retiring model: gemini-2.5-pro and gemini-2.5-flash shut down
|
|
@@ -106,6 +106,19 @@ export const MODEL_REGISTRY = {
|
|
|
106
106
|
fallbacks: ['claude-sonnet-5', 'gemini-3.1-pro-preview'],
|
|
107
107
|
},
|
|
108
108
|
// --- Top tier ---
|
|
109
|
+
'claude-opus-5-5': {
|
|
110
|
+
provider: 'anthropic',
|
|
111
|
+
supportsEffort: true,
|
|
112
|
+
defaultEffort: 'high',
|
|
113
|
+
apiModelId: 'claude-opus-5-5',
|
|
114
|
+
fallbacks: ['gpt-5.6-sol', 'gemini-3.1-pro-preview'],
|
|
115
|
+
// All verified live 2026-09-22: rejects `temperature` and forced
|
|
116
|
+
// tool_choice (400 each); thinking is always on and its tokens count
|
|
117
|
+
// against `maxTokens`.
|
|
118
|
+
supportsTemperature: false,
|
|
119
|
+
supportsForcedToolChoice: false,
|
|
120
|
+
thinksByDefault: true,
|
|
121
|
+
},
|
|
109
122
|
'claude-opus-5': {
|
|
110
123
|
provider: 'anthropic',
|
|
111
124
|
supportsEffort: true,
|
|
@@ -136,14 +149,14 @@ export const MODEL_REGISTRY = {
|
|
|
136
149
|
'gpt-5.6-sol': {
|
|
137
150
|
provider: 'openai',
|
|
138
151
|
apiModelId: 'gpt-5.6-sol',
|
|
139
|
-
fallbacks: ['claude-opus-5', 'gemini-3.1-pro-preview'],
|
|
152
|
+
fallbacks: ['claude-opus-5-5', 'gemini-3.1-pro-preview'],
|
|
140
153
|
supportsTemperature: false,
|
|
141
154
|
thinksByDefault: true,
|
|
142
155
|
},
|
|
143
156
|
'gpt-5.2': {
|
|
144
157
|
provider: 'openai',
|
|
145
158
|
apiModelId: 'gpt-5.2',
|
|
146
|
-
fallbacks: ['claude-opus-5', 'gemini-3.1-pro-preview'],
|
|
159
|
+
fallbacks: ['claude-opus-5-5', 'gemini-3.1-pro-preview'],
|
|
147
160
|
},
|
|
148
161
|
// Google's best Pro model as of 2026-08 — still Preview-labeled (no GA
|
|
149
162
|
// Gemini 3.x Pro exists), but the only Pro-class option once 2.5-pro
|
|
@@ -151,7 +164,7 @@ export const MODEL_REGISTRY = {
|
|
|
151
164
|
'gemini-3.1-pro-preview': {
|
|
152
165
|
provider: 'gemini',
|
|
153
166
|
apiModelId: 'gemini-3.1-pro-preview',
|
|
154
|
-
fallbacks: ['claude-opus-5', 'gpt-5.6-sol'],
|
|
167
|
+
fallbacks: ['claude-opus-5-5', 'gpt-5.6-sol'],
|
|
155
168
|
// Thinks by default (thinking_level defaults to `high`).
|
|
156
169
|
thinksByDefault: true,
|
|
157
170
|
// Rejects thinkingBudget 0 (400, verified 2026-08-09).
|
|
@@ -161,6 +174,6 @@ export const MODEL_REGISTRY = {
|
|
|
161
174
|
'gemini-2.5-pro': {
|
|
162
175
|
provider: 'gemini',
|
|
163
176
|
apiModelId: 'gemini-2.5-pro',
|
|
164
|
-
fallbacks: ['claude-opus-5', 'gpt-5.6-sol'],
|
|
177
|
+
fallbacks: ['claude-opus-5-5', 'gpt-5.6-sol'],
|
|
165
178
|
},
|
|
166
179
|
};
|
|
@@ -12,6 +12,51 @@ function classifyFinish(stopReason) {
|
|
|
12
12
|
default: return 'other';
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Keywords `output_config.format` rejects outright with a 400 (verified on
|
|
17
|
+
* Opus 5.5, 2026-09-22). `minItems` is also rejected for values other than 0
|
|
18
|
+
* or 1, handled separately; `minLength`/`maxLength`, `pattern` and `format`
|
|
19
|
+
* are accepted.
|
|
20
|
+
*/
|
|
21
|
+
const UNSUPPORTED_FORMAT_KEYWORDS = [
|
|
22
|
+
'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf',
|
|
23
|
+
'maxItems', 'uniqueItems',
|
|
24
|
+
];
|
|
25
|
+
/**
|
|
26
|
+
* Transform a portable JSON schema into the dialect Anthropic's structured
|
|
27
|
+
* outputs (`output_config.format`) accepts: every object schema must set
|
|
28
|
+
* `additionalProperties: false`, and the numeric/array bounds listed above are
|
|
29
|
+
* dropped. The model then no longer enforces those bounds — callers validate
|
|
30
|
+
* the parsed result as they already must for truncation. Unlike forced
|
|
31
|
+
* tool_use, optional properties stay optional. Returns a deep copy — the
|
|
32
|
+
* caller's schema object is reused across failover attempts.
|
|
33
|
+
*/
|
|
34
|
+
function toOutputFormatSchema(node) {
|
|
35
|
+
if (Array.isArray(node))
|
|
36
|
+
return node.map(toOutputFormatSchema);
|
|
37
|
+
if (node === null || typeof node !== 'object')
|
|
38
|
+
return node;
|
|
39
|
+
const out = {};
|
|
40
|
+
for (const [key, value] of Object.entries(node)) {
|
|
41
|
+
if (UNSUPPORTED_FORMAT_KEYWORDS.includes(key))
|
|
42
|
+
continue;
|
|
43
|
+
if (key === 'minItems' && typeof value === 'number' && value > 1)
|
|
44
|
+
continue;
|
|
45
|
+
// Maps whose values are schemas (not keywords): recurse per entry so a
|
|
46
|
+
// property that happens to be named `minimum` is kept.
|
|
47
|
+
if ((key === 'properties' || key === '$defs' || key === 'definitions')
|
|
48
|
+
&& value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
49
|
+
out[key] = Object.fromEntries(Object.entries(value).map(([k, v]) => [k, toOutputFormatSchema(v)]));
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
out[key] = toOutputFormatSchema(value);
|
|
53
|
+
}
|
|
54
|
+
const type = out.type;
|
|
55
|
+
if (type === 'object' || (Array.isArray(type) && type.includes('object'))) {
|
|
56
|
+
out.additionalProperties = false;
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
15
60
|
export const anthropicProvider = {
|
|
16
61
|
name: 'anthropic',
|
|
17
62
|
async complete(request) {
|
|
@@ -26,7 +71,7 @@ export const anthropicProvider = {
|
|
|
26
71
|
max_tokens: request.maxTokens,
|
|
27
72
|
messages: [{ role: 'user', content: request.prompt }],
|
|
28
73
|
};
|
|
29
|
-
// Some models (Opus 4.7+, Sonnet 5, Fable 5) reject `temperature`
|
|
74
|
+
// Some models (Opus 4.7+, Sonnet 5, Fable 5, Opus 5.5) reject `temperature`
|
|
30
75
|
// outright with a 400 — omit the field entirely rather than sending it.
|
|
31
76
|
if (request.supportsTemperature) {
|
|
32
77
|
body.temperature = request.temperature;
|
|
@@ -42,8 +87,16 @@ export const anthropicProvider = {
|
|
|
42
87
|
? [{ type: 'text', text: request.systemPrompt, cache_control: { type: 'ephemeral' } }]
|
|
43
88
|
: request.systemPrompt;
|
|
44
89
|
}
|
|
45
|
-
// JSON schema:
|
|
46
|
-
|
|
90
|
+
// JSON schema: a forced tool_use call where the model allows it, else
|
|
91
|
+
// structured outputs (Opus 5.5 rejects forced tool_choice).
|
|
92
|
+
const useOutputFormat = request.jsonSchema !== undefined && !request.supportsForcedToolChoice;
|
|
93
|
+
if (request.jsonSchema && useOutputFormat) {
|
|
94
|
+
body.output_config = {
|
|
95
|
+
...body.output_config,
|
|
96
|
+
format: { type: 'json_schema', schema: toOutputFormatSchema(request.jsonSchema.schema) },
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
else if (request.jsonSchema) {
|
|
47
100
|
body.tools = [{
|
|
48
101
|
name: request.jsonSchema.name,
|
|
49
102
|
description: `Generate structured output matching the ${request.jsonSchema.name} schema`,
|
|
@@ -103,7 +156,18 @@ export const anthropicProvider = {
|
|
|
103
156
|
const finishReason = data.stop_reason ?? undefined;
|
|
104
157
|
let text;
|
|
105
158
|
let json;
|
|
106
|
-
if (
|
|
159
|
+
if (useOutputFormat) {
|
|
160
|
+
text = data.content?.find((c) => c.type === 'text')?.text || '';
|
|
161
|
+
try {
|
|
162
|
+
json = text ? JSON.parse(text) : undefined;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
// Truncated or malformed body: surface as json undefined, like the
|
|
166
|
+
// tool_use path, so the caller's truncation handling applies.
|
|
167
|
+
json = undefined;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
else if (request.jsonSchema) {
|
|
107
171
|
const toolUse = data.content?.find((c) => c.type === 'tool_use');
|
|
108
172
|
json = toolUse?.input;
|
|
109
173
|
text = json !== undefined ? JSON.stringify(json) : '';
|
package/dist/llm/types.d.ts
CHANGED
|
@@ -176,6 +176,13 @@ export interface ProviderCallRequest {
|
|
|
176
176
|
* model would 400); non-zero budgets pass through unchanged.
|
|
177
177
|
*/
|
|
178
178
|
supportsThinkingBudgetZero: boolean;
|
|
179
|
+
/**
|
|
180
|
+
* Whether the target model accepts a forced `tool_choice`, per the registry
|
|
181
|
+
* entry's `supportsForcedToolChoice` flag. When `false`, the Anthropic
|
|
182
|
+
* adapter gets `jsonSchema` output through `output_config.format` instead
|
|
183
|
+
* of a forced tool call. Other providers ignore it.
|
|
184
|
+
*/
|
|
185
|
+
supportsForcedToolChoice: boolean;
|
|
179
186
|
jsonSchema?: JsonSchema;
|
|
180
187
|
apiKey: string;
|
|
181
188
|
logger?: Logger;
|
package/dist/log-core.d.ts
CHANGED
|
@@ -121,6 +121,10 @@ export interface EventFactoryOptions {
|
|
|
121
121
|
app: string;
|
|
122
122
|
source: 'server' | 'browser';
|
|
123
123
|
context?: Record<string, unknown>;
|
|
124
|
+
/** Process-level fields merged under `meta` on every event, read at event
|
|
125
|
+
* time (the server logger passes process-meta.ts's request-activity source).
|
|
126
|
+
* Ignored for browser events. The caller's own meta wins on a name clash. */
|
|
127
|
+
processMeta?: () => Record<string, unknown> | undefined;
|
|
124
128
|
}
|
|
125
129
|
export declare function makeEvent(opts: EventFactoryOptions, level: LogLevel, message: string, meta?: Record<string, unknown>): LogEvent;
|
|
126
130
|
/** The event for one logger call plus, the first time a site's meta breaks
|
package/dist/log-core.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// bounded, backed-off retries and per-batch identity
|
|
7
7
|
// (~/migration/research/logging-reliability-design.md): every undelivered
|
|
8
8
|
// batch either lands in Axiom or ends in an onGiveUp callback — the server
|
|
9
|
-
// logger turns those into stdout drop markers that
|
|
9
|
+
// logger turns those into stdout drop markers that watchdog's daily
|
|
10
10
|
// ship-failure check counts. makeEvent also guards the shape of `meta`: every
|
|
11
11
|
// distinct key is an Axiom column (see "Key-shape guard" below).
|
|
12
12
|
export const LOG_LEVELS = ['debug', 'info', 'warn', 'error'];
|
|
@@ -361,6 +361,11 @@ function buildEvent(opts, level, message, meta) {
|
|
|
361
361
|
if (rewrites.length > 0)
|
|
362
362
|
m = { ...m, logShape: rewrites.join('+') };
|
|
363
363
|
}
|
|
364
|
+
// Added after shaping: a fixed vocabulary that must not count against the
|
|
365
|
+
// site's leaf budget.
|
|
366
|
+
const processMeta = opts.source === 'server' ? opts.processMeta?.() : undefined;
|
|
367
|
+
if (processMeta)
|
|
368
|
+
m = { ...processMeta, ...m };
|
|
364
369
|
const event = {
|
|
365
370
|
_time: new Date().toISOString(),
|
|
366
371
|
app: opts.app,
|
package/dist/log.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ export interface LoggerOptions {
|
|
|
15
15
|
context?: Record<string, unknown>;
|
|
16
16
|
fetchFn?: typeof fetch;
|
|
17
17
|
}
|
|
18
|
-
/** Register a directly-constructed shipper (e.g.
|
|
18
|
+
/** Register a directly-constructed shipper (e.g. watchdog's platform-dataset
|
|
19
19
|
* shipper) so flushAllLoggers and shutdown cover it too. */
|
|
20
20
|
export declare function registerShipper(shipper: LogShipper): void;
|
|
21
21
|
/** Flush every registered shipper. `final` is the shutdown path: time-boxed
|
package/dist/log.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// log-core's key-shape guard rewrites offenders and WARNs once per site.
|
|
10
10
|
import { writeSync } from 'node:fs';
|
|
11
11
|
import { LogShipper, makeEvents, truncate, } from './log-core.js';
|
|
12
|
+
import { processMeta } from './process-meta.js';
|
|
12
13
|
// makeEvent/makeEvents are exported for app test fakes: routing a recording
|
|
13
14
|
// logger's calls through makeEvents applies the key-shape guard (strict under
|
|
14
15
|
// vitest) to fake-logged sites too.
|
|
@@ -18,7 +19,7 @@ export { LogShipper, makeEvent, makeEvents, serializeError, setLogShapeStrict, t
|
|
|
18
19
|
// with() children share their root's shipper, so registering loggers leaked
|
|
19
20
|
// one entry per child.
|
|
20
21
|
const allShippers = new Set();
|
|
21
|
-
/** Register a directly-constructed shipper (e.g.
|
|
22
|
+
/** Register a directly-constructed shipper (e.g. watchdog's platform-dataset
|
|
22
23
|
* shipper) so flushAllLoggers and shutdown cover it too. */
|
|
23
24
|
export function registerShipper(shipper) {
|
|
24
25
|
allShippers.add(shipper);
|
|
@@ -147,7 +148,7 @@ export function createLogger(opts) {
|
|
|
147
148
|
process.once('beforeExit', () => void shipper.finalFlush());
|
|
148
149
|
}
|
|
149
150
|
function build(context) {
|
|
150
|
-
const factory = { app: opts.app, source: 'server', context };
|
|
151
|
+
const factory = { app: opts.app, source: 'server', context, processMeta };
|
|
151
152
|
const emit = (level, message, meta) => {
|
|
152
153
|
for (const event of makeEvents(factory, level, message, meta)) {
|
|
153
154
|
if (stdout)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// The one process-wide source of fields the server logger merges under
|
|
2
|
+
// `meta` on every event. Today that is request activity (activity.ts),
|
|
3
|
+
// registered by whatever serves HTTP (trackActivity, so startServer and
|
|
4
|
+
// custom servers alike): a process that never registers — a Cloud Run Job,
|
|
5
|
+
// a CLI, a test — emits no such fields at all, so absence means "not a
|
|
6
|
+
// server" and 0 means "idle server". Internal: not part of the package's
|
|
7
|
+
// public surface, and kept apart from log.ts so neither the logger nor
|
|
8
|
+
// activity.ts depends on the other.
|
|
9
|
+
let source;
|
|
10
|
+
export function setProcessMetaSource(next) {
|
|
11
|
+
source = next;
|
|
12
|
+
}
|
|
13
|
+
/** The registered source's fields now; undefined when none is registered. */
|
|
14
|
+
export function processMeta() {
|
|
15
|
+
return source?.();
|
|
16
|
+
}
|
package/dist/rum.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Real-user monitoring: Core Web Vitals shipped to the Axiom `rum` dataset
|
|
2
|
-
// (targets: ~/
|
|
2
|
+
// (targets: ~/plasticine-way/docs/PERFORMANCE.md). Configured by the
|
|
3
3
|
// same pw-log-* meta tags as log-browser; token-less pages (local dev)
|
|
4
4
|
// no-op. Metrics finalize on tab-hide, so the shipper flushes there with
|
|
5
5
|
// keepalive fetch.
|
package/dist/s2s.js
CHANGED
|
@@ -99,7 +99,7 @@ export async function resolveS2sCaller(opts, headers, method, path) {
|
|
|
99
99
|
// ("Wrong number of segments in token: <jwt>", "Invalid token signature:
|
|
100
100
|
// <jwt>"), so the error is never logged as-is: the token's shape and a
|
|
101
101
|
// redacted message only (PW LESSONS 2026-09-02 — log the shape, never
|
|
102
|
-
// the credential). Found by
|
|
102
|
+
// the credential). Found by Mirror's device-key design review.
|
|
103
103
|
opts.logger?.warn('s2s token verification failed', {
|
|
104
104
|
path,
|
|
105
105
|
reason: redactToken(err instanceof Error ? err.message : String(err), m[1]),
|
package/dist/twitter-client.d.ts
CHANGED
|
@@ -55,7 +55,7 @@ export declare class TwitterApiError extends Error {
|
|
|
55
55
|
constructor(message: string, apiCalls: number);
|
|
56
56
|
}
|
|
57
57
|
export declare function assertTweetId(id: string): void;
|
|
58
|
-
/** Harvested from
|
|
58
|
+
/** Harvested from Reader/Mirror: bounded retries and timeout, four active
|
|
59
59
|
* requests per shared client. Credentials and upstream bodies are never logged. */
|
|
60
60
|
export declare class TwitterApiIoClient implements TwitterClient {
|
|
61
61
|
private readonly apiKey;
|
package/dist/twitter-client.js
CHANGED
|
@@ -17,7 +17,7 @@ export function assertTweetId(id) {
|
|
|
17
17
|
if (!/^\d{1,25}$/.test(id))
|
|
18
18
|
throw new Error('Invalid tweet id');
|
|
19
19
|
}
|
|
20
|
-
/** Harvested from
|
|
20
|
+
/** Harvested from Reader/Mirror: bounded retries and timeout, four active
|
|
21
21
|
* requests per shared client. Credentials and upstream bodies are never logged. */
|
|
22
22
|
export class TwitterApiIoClient {
|
|
23
23
|
apiKey;
|
package/dist/twitter-mirror.js
CHANGED
|
@@ -50,10 +50,10 @@ export class MirrorTweetClient {
|
|
|
50
50
|
signal: AbortSignal.timeout(30_000),
|
|
51
51
|
});
|
|
52
52
|
if (!response.ok)
|
|
53
|
-
throw new Error(`
|
|
53
|
+
throw new Error(`Mirror tweets query HTTP ${response.status}`);
|
|
54
54
|
const data = await response.json();
|
|
55
55
|
if (!Array.isArray(data.records))
|
|
56
|
-
throw new Error('
|
|
56
|
+
throw new Error('Mirror response has no records array');
|
|
57
57
|
const tweets = new Map();
|
|
58
58
|
for (const r of data.records) {
|
|
59
59
|
if (r.is_deleted || r.deleted_at)
|
package/dist/twitter.js
CHANGED
|
@@ -67,7 +67,7 @@ export async function assembleThread(id, options) {
|
|
|
67
67
|
add(await mirror.getTweetsByIds(missing), 'mirror2');
|
|
68
68
|
}
|
|
69
69
|
catch (error) {
|
|
70
|
-
warning('
|
|
70
|
+
warning('Mirror lookup failed', error);
|
|
71
71
|
}
|
|
72
72
|
const absent = missing.filter((key) => !records.has(key));
|
|
73
73
|
if (absent.length)
|
|
@@ -85,10 +85,10 @@ export async function assembleThread(id, options) {
|
|
|
85
85
|
add(conversation.tweets, 'mirror2');
|
|
86
86
|
archivedAuthor = conversation.complete && conversation.tweets.some((tweet) => sameAuthor(tweet, linked));
|
|
87
87
|
if (!conversation.complete)
|
|
88
|
-
warning('
|
|
88
|
+
warning('Mirror conversation reached its row limit; searching the author via API');
|
|
89
89
|
}
|
|
90
90
|
catch (error) {
|
|
91
|
-
warning('
|
|
91
|
+
warning('Mirror conversation failed', error);
|
|
92
92
|
}
|
|
93
93
|
if (!archivedAuthor && /^\w+$/.test(linked.author.userName)) {
|
|
94
94
|
let cursor;
|
package/package.json
CHANGED