@steve31415/baselib 3.27.0 → 3.29.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 +1 -1
- package/dist/connector.d.ts +149 -0
- package/dist/connector.js +371 -0
- package/dist/llm/complete.d.ts +8 -1
- package/dist/llm/complete.js +28 -2
- package/dist/llm/index.d.ts +1 -1
- package/dist/llm/index.js +1 -1
- package/dist/llm/models.d.ts +15 -5
- package/dist/llm/models.js +18 -1
- package/dist/llm/providers/gemini.js +9 -4
- package/dist/llm/types.d.ts +28 -13
- package/dist/llm/types.js +7 -0
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ What it provides and why: `docs/SPEC.md`. How it's put together:
|
|
|
5
5
|
`docs/IMPL.md`. Full design rationale with alternatives:
|
|
6
6
|
`~/migration/research/base-services-design.md`.
|
|
7
7
|
|
|
8
|
-
Server subpath exports: `config`, `log`, `auth`, `oauth-resource`, `s2s`, `db`, `http`, `sync`,
|
|
8
|
+
Server subpath exports: `config`, `log`, `auth`, `oauth-resource`, `connector`, `s2s`, `db`, `http`, `sync`,
|
|
9
9
|
`app-update`, `llm`, `email`, and `twitter`. Browser exports: `log-browser`, `rum`,
|
|
10
10
|
`slow-fetch`, `sync-browser`, and `app-update-browser`.
|
|
11
11
|
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
3
|
+
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import type { Hono, MiddlewareHandler } from 'hono';
|
|
5
|
+
import type { z } from 'zod';
|
|
6
|
+
import type { UserAccessPolicy } from './auth.js';
|
|
7
|
+
import type { Logger } from './log-core.js';
|
|
8
|
+
import type { ConnectorUser } from './oauth-resource.js';
|
|
9
|
+
import type { Grants } from './s2s.js';
|
|
10
|
+
export type { ConnectorUser } from './oauth-resource.js';
|
|
11
|
+
/** The headers the front door asserts a user with. */
|
|
12
|
+
export declare const CONNECTOR_HEADERS: {
|
|
13
|
+
readonly user: "x-connector-user";
|
|
14
|
+
readonly role: "x-connector-role";
|
|
15
|
+
readonly scope: "x-connector-scope";
|
|
16
|
+
readonly client: "x-connector-client";
|
|
17
|
+
readonly grant: "x-connector-grant";
|
|
18
|
+
readonly name: "x-connector-name";
|
|
19
|
+
};
|
|
20
|
+
/** The headers that carry `user` to an app (the front door's side). */
|
|
21
|
+
export declare function connectorIdentityHeaders(user: ConnectorUser): Record<string, string>;
|
|
22
|
+
/** The identity the headers assert, or null when any required one is
|
|
23
|
+
* missing or malformed. Header values are what the front door set; an
|
|
24
|
+
* attacker who can set them has already passed the s2s check. */
|
|
25
|
+
export declare function parseConnectorIdentityHeaders(header: (name: string) => string | undefined): ConnectorUser | null;
|
|
26
|
+
export interface ForwardedConnectorOptions {
|
|
27
|
+
/** This app's canonical URL: the audience the front door mints its OIDC
|
|
28
|
+
* token for. */
|
|
29
|
+
audience: string;
|
|
30
|
+
/** The app's checked-in grants; the front door's identity needs `POST /mcp`. */
|
|
31
|
+
grants: Grants;
|
|
32
|
+
/** Which users this app admits — copied from its requireIdentity call. */
|
|
33
|
+
userAccess: UserAccessPolicy;
|
|
34
|
+
logger?: Logger;
|
|
35
|
+
/** Per-caller ceiling for the forwarding service (default 6000/min: one
|
|
36
|
+
* identity carries every user's traffic). */
|
|
37
|
+
rateLimitPerMin?: number;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Admits the front door on an app's `/mcp` and nothing else: the s2s caller
|
|
41
|
+
* must verify and be granted the route (the coding agent always is), then
|
|
42
|
+
* the `X-Connector-*` headers name the user, whose role the app's own access
|
|
43
|
+
* policy is applied to. Sets `c.var.connector`; never `identity`/`user`.
|
|
44
|
+
*/
|
|
45
|
+
export declare function requireForwardedConnectorUser(opts: ForwardedConnectorOptions): MiddlewareHandler;
|
|
46
|
+
/** A tool error the model can read and act on. Anything else thrown by a
|
|
47
|
+
* tool is an internal failure: logged at ERROR, answered generically. */
|
|
48
|
+
export declare class ConnectorError extends Error {
|
|
49
|
+
readonly code: string;
|
|
50
|
+
constructor(code: string, message: string);
|
|
51
|
+
}
|
|
52
|
+
/** Content-free facts about one call, for its log line. */
|
|
53
|
+
export type CallFacts = Record<string, string | number | boolean | null>;
|
|
54
|
+
export interface ToolOutcome {
|
|
55
|
+
result: unknown;
|
|
56
|
+
facts: CallFacts;
|
|
57
|
+
}
|
|
58
|
+
export interface ConnectorToolDefinition<Deps, Shape extends z.ZodRawShape = z.ZodRawShape> {
|
|
59
|
+
title: string;
|
|
60
|
+
description: string;
|
|
61
|
+
inputSchema: Shape;
|
|
62
|
+
/** Reads only; needs `<app>:read`. Otherwise `<app>:write`. */
|
|
63
|
+
readOnly: boolean;
|
|
64
|
+
/** A write that overwrites or discards existing content. */
|
|
65
|
+
destructive?: boolean;
|
|
66
|
+
run(deps: Deps, user: ConnectorUser, args: z.infer<z.ZodObject<Shape>>): Promise<ToolOutcome>;
|
|
67
|
+
}
|
|
68
|
+
export interface ConnectorToolTable<Deps> {
|
|
69
|
+
/** The tool-name and scope prefix: `todo` → `todo_*`, `todo:read`. */
|
|
70
|
+
app: string;
|
|
71
|
+
scopes: readonly [read: string, write: string];
|
|
72
|
+
tools: Readonly<Record<string, ConnectorToolDefinition<Deps>>>;
|
|
73
|
+
}
|
|
74
|
+
export declare function connectorScopes(app: string): readonly [string, string];
|
|
75
|
+
/**
|
|
76
|
+
* A typed tool table for one app: `tool()` captures each tool's argument
|
|
77
|
+
* shape; `table()` checks the names and fixes the scopes.
|
|
78
|
+
*
|
|
79
|
+
* const { tool, table } = connectorToolkit<TodoDeps>('todo')
|
|
80
|
+
* export const TOOLS = table({ todo_add_item: tool({ … }) })
|
|
81
|
+
*/
|
|
82
|
+
export declare function connectorToolkit<Deps>(app: string): {
|
|
83
|
+
tool<Shape extends z.ZodRawShape>(definition: ConnectorToolDefinition<Deps, Shape>): ConnectorToolDefinition<Deps, Shape>;
|
|
84
|
+
table(tools: Record<string, ConnectorToolDefinition<Deps>>): ConnectorToolTable<Deps>;
|
|
85
|
+
};
|
|
86
|
+
/** The scope a tool needs, or null for a name that is not a tool. */
|
|
87
|
+
export declare function connectorToolScope<Deps>(table: ConnectorToolTable<Deps>, name: unknown): string | null;
|
|
88
|
+
/**
|
|
89
|
+
* The scopes the `tools/call` messages in a JSON-RPC body need. Checked by
|
|
90
|
+
* the route before the SDK runs, because only an HTTP 403 with a challenge —
|
|
91
|
+
* never a tool result — makes the client ask the user for more access.
|
|
92
|
+
*/
|
|
93
|
+
export declare function requiredConnectorScopes<Deps>(table: ConnectorToolTable<Deps>, body: unknown): Set<string>;
|
|
94
|
+
export interface ConnectorServerInfo {
|
|
95
|
+
name: string;
|
|
96
|
+
title: string;
|
|
97
|
+
version?: string;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* One MCP server per request, bound to the caller: the endpoint is stateless,
|
|
101
|
+
* so nothing is shared between calls or users.
|
|
102
|
+
*/
|
|
103
|
+
export declare function buildConnectorServer<Deps>(table: ConnectorToolTable<Deps>, deps: Deps, user: ConnectorUser, logger: Logger, info: ConnectorServerInfo, instructions?: string): McpServer;
|
|
104
|
+
/** Answer one already-parsed JSON-RPC POST for this caller. */
|
|
105
|
+
export declare function handleConnectorRequest(server: McpServer, request: Request, parsedBody: unknown): Promise<Response>;
|
|
106
|
+
/**
|
|
107
|
+
* The table's tool definitions exactly as `tools/list` returns them (the
|
|
108
|
+
* SDK's own JSON Schema rendering), obtained by listing them over an
|
|
109
|
+
* in-memory transport once. Served publicly: definitions are not secret, and
|
|
110
|
+
* the front door merges them into its own `tools/list`.
|
|
111
|
+
*/
|
|
112
|
+
export declare function connectorToolDefinitions<Deps>(table: ConnectorToolTable<Deps>, info: ConnectorServerInfo): Promise<Tool[]>;
|
|
113
|
+
/** JSON-RPC bodies are small except for a page of Markdown (apps cap their
|
|
114
|
+
* own content at 1 MiB). */
|
|
115
|
+
export declare const MAX_CONNECTOR_REQUEST_BYTES: number;
|
|
116
|
+
export interface MountConnectorOptions<Deps> extends ForwardedConnectorOptions {
|
|
117
|
+
table: ConnectorToolTable<Deps>;
|
|
118
|
+
deps: Deps;
|
|
119
|
+
logger: Logger;
|
|
120
|
+
serverInfo: ConnectorServerInfo;
|
|
121
|
+
instructions?: string;
|
|
122
|
+
maxRequestBytes?: number;
|
|
123
|
+
/** Replaces the forwarded-identity middleware (a transition hook: Notes
|
|
124
|
+
* admits its old bearer tokens through it for one deploy). Must set
|
|
125
|
+
* `c.var.connector` or answer. */
|
|
126
|
+
admit?: MiddlewareHandler;
|
|
127
|
+
}
|
|
128
|
+
export interface MountedConnector {
|
|
129
|
+
/** The public tool-definition document, as `GET /mcp/tools` serves it. */
|
|
130
|
+
definitions: () => Promise<Tool[]>;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Serve the table at `/mcp` (POST only, forwarded identity, size cap, strict
|
|
134
|
+
* JSON, the scope gate, one server per request) and its definitions at
|
|
135
|
+
* `GET /mcp/tools` (public). Mount before the app's own identity middleware:
|
|
136
|
+
* a connector must never be looked at by `requireIdentity`, and vice versa.
|
|
137
|
+
*/
|
|
138
|
+
export declare function mountConnector<Deps>(app: Hono, opts: MountConnectorOptions<Deps>): MountedConnector;
|
|
139
|
+
/**
|
|
140
|
+
* A real MCP client whose HTTP goes straight into a Hono app, for app tests:
|
|
141
|
+
* `headers` carry the caller (`X-Test-S2S-Caller` plus the X-Connector-*
|
|
142
|
+
* identity, or whatever the route's `admit` expects).
|
|
143
|
+
*/
|
|
144
|
+
export declare function connectorTestClient(request: (path: string, init: RequestInit) => Response | Promise<Response>, headers: Record<string, string>, path?: string): Promise<Client>;
|
|
145
|
+
/** Call a tool and unpack the JSON text result the kit produces. */
|
|
146
|
+
export declare function callConnectorTool(client: Client, name: string, args: Record<string, unknown>): Promise<{
|
|
147
|
+
isError: boolean;
|
|
148
|
+
value: Record<string, unknown>;
|
|
149
|
+
}>;
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// The app side of the fleet's AI connector (PW docs/SECURITY.md "Connector
|
|
2
|
+
// access"): everything an app needs to serve its MCP tool table at /mcp,
|
|
3
|
+
// minus the tools themselves.
|
|
4
|
+
//
|
|
5
|
+
// claude.ai talks to one URL, the auth service's front door. The front door
|
|
6
|
+
// verifies the user's OAuth token and relays each tool call to the owning app
|
|
7
|
+
// over the ordinary service-to-service hop: a Google-signed OIDC token for
|
|
8
|
+
// the app's audience, minted as auth's own service account, plus the user's
|
|
9
|
+
// identity in X-Connector-* headers. The user's bearer never reaches an app.
|
|
10
|
+
// So an app's /mcp admits exactly one kind of caller — a granted service
|
|
11
|
+
// asserting a user — and the identity it yields is a `connector`, never
|
|
12
|
+
// `identity`/`user`: a connector cannot reach an app's ordinary routes.
|
|
13
|
+
//
|
|
14
|
+
// Tool names carry the app's prefix (`todo_add_item`): in claude.ai the name
|
|
15
|
+
// is the only thing the model reliably sees, and the front door routes by it.
|
|
16
|
+
// A tool's scope follows from `readOnly`: `<app>:read` or `<app>:write`.
|
|
17
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
18
|
+
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
|
|
19
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
20
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
21
|
+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
|
22
|
+
import { createMiddleware } from 'hono/factory';
|
|
23
|
+
import { ACCESS_DENIED_CODE, userAllowed } from './auth.js';
|
|
24
|
+
import { serializeError } from './log-core.js';
|
|
25
|
+
import { insufficientScope } from './oauth-resource.js';
|
|
26
|
+
import { RateLimiter, resolveS2sCaller } from './s2s.js';
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// The forwarded identity
|
|
29
|
+
/** The headers the front door asserts a user with. */
|
|
30
|
+
export const CONNECTOR_HEADERS = {
|
|
31
|
+
user: 'x-connector-user',
|
|
32
|
+
role: 'x-connector-role',
|
|
33
|
+
scope: 'x-connector-scope',
|
|
34
|
+
client: 'x-connector-client',
|
|
35
|
+
grant: 'x-connector-grant',
|
|
36
|
+
name: 'x-connector-name',
|
|
37
|
+
};
|
|
38
|
+
/** The headers that carry `user` to an app (the front door's side). */
|
|
39
|
+
export function connectorIdentityHeaders(user) {
|
|
40
|
+
return {
|
|
41
|
+
[CONNECTOR_HEADERS.user]: user.email,
|
|
42
|
+
[CONNECTOR_HEADERS.role]: user.role,
|
|
43
|
+
[CONNECTOR_HEADERS.scope]: [...user.scopes].join(' '),
|
|
44
|
+
[CONNECTOR_HEADERS.client]: user.clientId,
|
|
45
|
+
[CONNECTOR_HEADERS.grant]: user.grantId,
|
|
46
|
+
...(user.name ? { [CONNECTOR_HEADERS.name]: user.name } : {}),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function parseRole(value) {
|
|
50
|
+
return value === 'owner' || value === 'invited' ? value : undefined;
|
|
51
|
+
}
|
|
52
|
+
/** The identity the headers assert, or null when any required one is
|
|
53
|
+
* missing or malformed. Header values are what the front door set; an
|
|
54
|
+
* attacker who can set them has already passed the s2s check. */
|
|
55
|
+
export function parseConnectorIdentityHeaders(header) {
|
|
56
|
+
const email = header(CONNECTOR_HEADERS.user);
|
|
57
|
+
const role = parseRole(header(CONNECTOR_HEADERS.role));
|
|
58
|
+
const clientId = header(CONNECTOR_HEADERS.client);
|
|
59
|
+
const grantId = header(CONNECTOR_HEADERS.grant);
|
|
60
|
+
const scope = header(CONNECTOR_HEADERS.scope);
|
|
61
|
+
if (!email || !role || !clientId || !grantId || scope === undefined)
|
|
62
|
+
return null;
|
|
63
|
+
const name = header(CONNECTOR_HEADERS.name);
|
|
64
|
+
return {
|
|
65
|
+
type: 'connector',
|
|
66
|
+
email,
|
|
67
|
+
role,
|
|
68
|
+
scopes: new Set(scope.split(' ').filter(Boolean)),
|
|
69
|
+
clientId,
|
|
70
|
+
grantId,
|
|
71
|
+
...(name ? { name } : {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Admits the front door on an app's `/mcp` and nothing else: the s2s caller
|
|
76
|
+
* must verify and be granted the route (the coding agent always is), then
|
|
77
|
+
* the `X-Connector-*` headers name the user, whose role the app's own access
|
|
78
|
+
* policy is applied to. Sets `c.var.connector`; never `identity`/`user`.
|
|
79
|
+
*/
|
|
80
|
+
export function requireForwardedConnectorUser(opts) {
|
|
81
|
+
const limiter = new RateLimiter(opts.rateLimitPerMin ?? 6000);
|
|
82
|
+
return createMiddleware(async (c, next) => {
|
|
83
|
+
c.header('cache-control', 'no-store');
|
|
84
|
+
const resolved = await resolveS2sCaller({ audience: opts.audience, grants: opts.grants, logger: opts.logger, limiter }, { authorization: c.req.header('authorization'), testCaller: c.req.header('x-test-s2s-caller') }, c.req.method, c.req.path);
|
|
85
|
+
if (!resolved.caller) {
|
|
86
|
+
const failure = resolved.failure;
|
|
87
|
+
return c.json({ error: failure.error }, failure.status);
|
|
88
|
+
}
|
|
89
|
+
const user = parseConnectorIdentityHeaders((name) => c.req.header(name));
|
|
90
|
+
if (!user) {
|
|
91
|
+
opts.logger?.warn('connector identity headers missing or malformed', {
|
|
92
|
+
caller: resolved.caller.email,
|
|
93
|
+
path: c.req.path,
|
|
94
|
+
});
|
|
95
|
+
return c.json({ error: 'connector identity headers missing' }, 400);
|
|
96
|
+
}
|
|
97
|
+
if (!userAllowed(opts.userAccess, user)) {
|
|
98
|
+
opts.logger?.warn('connector user access denied', {
|
|
99
|
+
email: user.email,
|
|
100
|
+
policy: opts.userAccess,
|
|
101
|
+
path: c.req.path,
|
|
102
|
+
});
|
|
103
|
+
return c.json({ error: 'not enabled for this account', code: ACCESS_DENIED_CODE, email: user.email }, 403);
|
|
104
|
+
}
|
|
105
|
+
c.set('connector', user);
|
|
106
|
+
await next();
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Tool tables
|
|
111
|
+
/** A tool error the model can read and act on. Anything else thrown by a
|
|
112
|
+
* tool is an internal failure: logged at ERROR, answered generically. */
|
|
113
|
+
export class ConnectorError extends Error {
|
|
114
|
+
code;
|
|
115
|
+
constructor(code, message) {
|
|
116
|
+
super(message);
|
|
117
|
+
this.code = code;
|
|
118
|
+
this.name = 'ConnectorError';
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export function connectorScopes(app) {
|
|
122
|
+
return [`${app}:read`, `${app}:write`];
|
|
123
|
+
}
|
|
124
|
+
const APP_NAME = /^[a-z][a-z0-9]*$/;
|
|
125
|
+
const TOOL_NAME = /^[a-z][a-z0-9_]*$/;
|
|
126
|
+
/**
|
|
127
|
+
* A typed tool table for one app: `tool()` captures each tool's argument
|
|
128
|
+
* shape; `table()` checks the names and fixes the scopes.
|
|
129
|
+
*
|
|
130
|
+
* const { tool, table } = connectorToolkit<TodoDeps>('todo')
|
|
131
|
+
* export const TOOLS = table({ todo_add_item: tool({ … }) })
|
|
132
|
+
*/
|
|
133
|
+
export function connectorToolkit(app) {
|
|
134
|
+
if (!APP_NAME.test(app))
|
|
135
|
+
throw new Error(`connector app name must be lowercase alphanumeric: ${app}`);
|
|
136
|
+
return {
|
|
137
|
+
tool(definition) {
|
|
138
|
+
return definition;
|
|
139
|
+
},
|
|
140
|
+
table(tools) {
|
|
141
|
+
for (const name of Object.keys(tools)) {
|
|
142
|
+
if (!TOOL_NAME.test(name) || !name.startsWith(`${app}_`)) {
|
|
143
|
+
throw new Error(`connector tool name must be ${app}_<name>: ${name}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { app, scopes: connectorScopes(app), tools };
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/** The scope a tool needs, or null for a name that is not a tool. */
|
|
151
|
+
export function connectorToolScope(table, name) {
|
|
152
|
+
if (typeof name !== 'string' || !Object.hasOwn(table.tools, name))
|
|
153
|
+
return null;
|
|
154
|
+
return table.tools[name].readOnly ? table.scopes[0] : table.scopes[1];
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The scopes the `tools/call` messages in a JSON-RPC body need. Checked by
|
|
158
|
+
* the route before the SDK runs, because only an HTTP 403 with a challenge —
|
|
159
|
+
* never a tool result — makes the client ask the user for more access.
|
|
160
|
+
*/
|
|
161
|
+
export function requiredConnectorScopes(table, body) {
|
|
162
|
+
const needed = new Set();
|
|
163
|
+
for (const message of Array.isArray(body) ? body : [body]) {
|
|
164
|
+
if (message === null || typeof message !== 'object')
|
|
165
|
+
continue;
|
|
166
|
+
const { method, params } = message;
|
|
167
|
+
if (method !== 'tools/call')
|
|
168
|
+
continue;
|
|
169
|
+
const scope = connectorToolScope(table, params?.name);
|
|
170
|
+
if (scope !== null)
|
|
171
|
+
needed.add(scope);
|
|
172
|
+
}
|
|
173
|
+
return needed;
|
|
174
|
+
}
|
|
175
|
+
function textResult(value, isError = false) {
|
|
176
|
+
return {
|
|
177
|
+
content: [{ type: 'text', text: JSON.stringify(value) }],
|
|
178
|
+
...(isError ? { isError: true } : {}),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* One MCP server per request, bound to the caller: the endpoint is stateless,
|
|
183
|
+
* so nothing is shared between calls or users.
|
|
184
|
+
*/
|
|
185
|
+
export function buildConnectorServer(table, deps, user, logger, info, instructions) {
|
|
186
|
+
const server = new McpServer({ name: info.name, title: info.title, version: info.version ?? '1.0.0' }, instructions ? { instructions } : {});
|
|
187
|
+
for (const [name, definition] of Object.entries(table.tools)) {
|
|
188
|
+
const scope = definition.readOnly ? table.scopes[0] : table.scopes[1];
|
|
189
|
+
server.registerTool(name, {
|
|
190
|
+
title: definition.title,
|
|
191
|
+
description: definition.description,
|
|
192
|
+
inputSchema: definition.inputSchema,
|
|
193
|
+
annotations: {
|
|
194
|
+
readOnlyHint: definition.readOnly,
|
|
195
|
+
...(definition.readOnly ? {} : { destructiveHint: definition.destructive === true }),
|
|
196
|
+
openWorldHint: false,
|
|
197
|
+
},
|
|
198
|
+
}, async (args) => {
|
|
199
|
+
const started = Date.now();
|
|
200
|
+
// Identifiers, counts and the outcome only — never arguments or
|
|
201
|
+
// results, which are the user's content.
|
|
202
|
+
const log = (outcome, facts) => logger.info('connector tool call', {
|
|
203
|
+
tool: name,
|
|
204
|
+
user: user.email,
|
|
205
|
+
clientId: user.clientId,
|
|
206
|
+
grantId: user.grantId,
|
|
207
|
+
outcome,
|
|
208
|
+
elapsed_ms: Date.now() - started,
|
|
209
|
+
...facts,
|
|
210
|
+
});
|
|
211
|
+
// The route's scope gate answers 403 first; this holds if it is bypassed.
|
|
212
|
+
if (!user.scopes.has(scope)) {
|
|
213
|
+
log('insufficient_scope', {});
|
|
214
|
+
return textResult({ error: 'insufficient_scope' }, true);
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
const { result, facts } = await definition.run(deps, user, args);
|
|
218
|
+
log('ok', facts);
|
|
219
|
+
return textResult(result);
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
if (error instanceof ConnectorError) {
|
|
223
|
+
log(error.code, {});
|
|
224
|
+
return textResult({ error: error.code, message: error.message }, true);
|
|
225
|
+
}
|
|
226
|
+
log('internal', {});
|
|
227
|
+
logger.error('connector tool failed', { tool: name, user: user.email, error: serializeError(error) });
|
|
228
|
+
return textResult({ error: 'internal', message: `the ${info.title} server failed to complete this call` }, true);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return server;
|
|
233
|
+
}
|
|
234
|
+
/** Answer one already-parsed JSON-RPC POST for this caller. */
|
|
235
|
+
export async function handleConnectorRequest(server, request, parsedBody) {
|
|
236
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
237
|
+
sessionIdGenerator: undefined,
|
|
238
|
+
enableJsonResponse: true,
|
|
239
|
+
});
|
|
240
|
+
try {
|
|
241
|
+
await server.connect(transport);
|
|
242
|
+
return await transport.handleRequest(request, { parsedBody });
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
await server.close();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const NO_USER = {
|
|
249
|
+
type: 'connector',
|
|
250
|
+
email: 'definitions@invalid',
|
|
251
|
+
role: 'owner',
|
|
252
|
+
scopes: new Set(),
|
|
253
|
+
clientId: 'definitions',
|
|
254
|
+
grantId: 'definitions',
|
|
255
|
+
};
|
|
256
|
+
const silentLogger = {
|
|
257
|
+
debug: () => { },
|
|
258
|
+
info: () => { },
|
|
259
|
+
warn: () => { },
|
|
260
|
+
error: () => { },
|
|
261
|
+
with: () => silentLogger,
|
|
262
|
+
flush: async () => { },
|
|
263
|
+
};
|
|
264
|
+
/**
|
|
265
|
+
* The table's tool definitions exactly as `tools/list` returns them (the
|
|
266
|
+
* SDK's own JSON Schema rendering), obtained by listing them over an
|
|
267
|
+
* in-memory transport once. Served publicly: definitions are not secret, and
|
|
268
|
+
* the front door merges them into its own `tools/list`.
|
|
269
|
+
*/
|
|
270
|
+
export async function connectorToolDefinitions(table, info) {
|
|
271
|
+
const server = buildConnectorServer(table, undefined, NO_USER, silentLogger, info);
|
|
272
|
+
const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
|
|
273
|
+
const client = new Client({ name: 'definitions', version: '0.0.0' });
|
|
274
|
+
try {
|
|
275
|
+
await server.connect(serverSide);
|
|
276
|
+
await client.connect(clientSide);
|
|
277
|
+
return (await client.listTools()).tools;
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
await client.close();
|
|
281
|
+
await server.close();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// The route
|
|
286
|
+
/** JSON-RPC bodies are small except for a page of Markdown (apps cap their
|
|
287
|
+
* own content at 1 MiB). */
|
|
288
|
+
export const MAX_CONNECTOR_REQUEST_BYTES = 2 * 1024 * 1024;
|
|
289
|
+
/**
|
|
290
|
+
* Serve the table at `/mcp` (POST only, forwarded identity, size cap, strict
|
|
291
|
+
* JSON, the scope gate, one server per request) and its definitions at
|
|
292
|
+
* `GET /mcp/tools` (public). Mount before the app's own identity middleware:
|
|
293
|
+
* a connector must never be looked at by `requireIdentity`, and vice versa.
|
|
294
|
+
*/
|
|
295
|
+
export function mountConnector(app, opts) {
|
|
296
|
+
const maxBytes = opts.maxRequestBytes ?? MAX_CONNECTOR_REQUEST_BYTES;
|
|
297
|
+
const admit = opts.admit ?? requireForwardedConnectorUser(opts);
|
|
298
|
+
let definitions;
|
|
299
|
+
const listDefinitions = () => (definitions ??= connectorToolDefinitions(opts.table, opts.serverInfo));
|
|
300
|
+
app.get('/mcp/tools', async (c) => {
|
|
301
|
+
c.header('cache-control', 'no-store');
|
|
302
|
+
return c.json({ tools: await listDefinitions() });
|
|
303
|
+
});
|
|
304
|
+
// Stateless JSON-RPC over POST: no server-initiated stream, no session.
|
|
305
|
+
// Answered before admission, so the SDK client's probe for a stream (a
|
|
306
|
+
// GET) is a plain 405 and not a grants refusal logged at ERROR.
|
|
307
|
+
const postOnly = createMiddleware(async (c, next) => {
|
|
308
|
+
if (c.req.method !== 'POST') {
|
|
309
|
+
c.header('cache-control', 'no-store');
|
|
310
|
+
return c.json({ error: 'method_not_allowed' }, 405, { allow: 'POST' });
|
|
311
|
+
}
|
|
312
|
+
await next();
|
|
313
|
+
});
|
|
314
|
+
app.all('/mcp', postOnly, admit, async (c) => {
|
|
315
|
+
c.header('cache-control', 'no-store');
|
|
316
|
+
const declared = Number(c.req.header('content-length') ?? '0');
|
|
317
|
+
if (!Number.isFinite(declared) || declared > maxBytes) {
|
|
318
|
+
return c.json({ error: 'request_too_large' }, 413);
|
|
319
|
+
}
|
|
320
|
+
const raw = await c.req.arrayBuffer();
|
|
321
|
+
if (raw.byteLength > maxBytes)
|
|
322
|
+
return c.json({ error: 'request_too_large' }, 413);
|
|
323
|
+
let body;
|
|
324
|
+
try {
|
|
325
|
+
body = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(raw));
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
return c.json({ jsonrpc: '2.0', error: { code: -32700, message: 'Parse error' }, id: null }, 400);
|
|
329
|
+
}
|
|
330
|
+
const user = c.get('connector');
|
|
331
|
+
for (const scope of requiredConnectorScopes(opts.table, body)) {
|
|
332
|
+
if (!user.scopes.has(scope)) {
|
|
333
|
+
// The front door rewrites this challenge to its own resource and the
|
|
334
|
+
// scopes it offers; what matters here is the error code.
|
|
335
|
+
return insufficientScope(c, { resource: `${opts.audience}/mcp`, scopes: opts.table.scopes });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const server = buildConnectorServer(opts.table, opts.deps, user, opts.logger, opts.serverInfo, opts.instructions);
|
|
339
|
+
return handleConnectorRequest(server, c.req.raw, body);
|
|
340
|
+
});
|
|
341
|
+
return { definitions: listDefinitions };
|
|
342
|
+
}
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
// Test support
|
|
345
|
+
/**
|
|
346
|
+
* A real MCP client whose HTTP goes straight into a Hono app, for app tests:
|
|
347
|
+
* `headers` carry the caller (`X-Test-S2S-Caller` plus the X-Connector-*
|
|
348
|
+
* identity, or whatever the route's `admit` expects).
|
|
349
|
+
*/
|
|
350
|
+
export async function connectorTestClient(request, headers, path = '/mcp') {
|
|
351
|
+
const client = new Client({ name: 'connector-test', version: '0.0.0' });
|
|
352
|
+
const transport = new StreamableHTTPClientTransport(new URL(`http://connector.test${path}`), {
|
|
353
|
+
fetch: async (input, init) => {
|
|
354
|
+
const merged = new Headers(init?.headers);
|
|
355
|
+
for (const [name, value] of Object.entries(headers))
|
|
356
|
+
merged.set(name, value);
|
|
357
|
+
return request(new URL(String(input)).pathname, { ...init, headers: merged });
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
await client.connect(transport);
|
|
361
|
+
return client;
|
|
362
|
+
}
|
|
363
|
+
/** Call a tool and unpack the JSON text result the kit produces. */
|
|
364
|
+
export async function callConnectorTool(client, name, args) {
|
|
365
|
+
const result = await client.callTool({ name, arguments: args });
|
|
366
|
+
const content = result.content;
|
|
367
|
+
if (content.length !== 1 || content[0].type !== 'text') {
|
|
368
|
+
throw new Error(`unexpected tool result shape for ${name}`);
|
|
369
|
+
}
|
|
370
|
+
return { isError: result.isError === true, value: JSON.parse(content[0].text) };
|
|
371
|
+
}
|
package/dist/llm/complete.d.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import type { LLMRequest, LLMResponse } from './types.js';
|
|
1
|
+
import type { LLMRequest, LLMResponse, Effort } from './types.js';
|
|
2
|
+
import type { ModelEntry } from './models.js';
|
|
3
|
+
/**
|
|
4
|
+
* The effort level to send a model: none when the model has no effort
|
|
5
|
+
* control, otherwise the requested level clamped to the nearest one the
|
|
6
|
+
* model accepts (e.g. `minimal` → `low` on models without a `minimal`).
|
|
7
|
+
*/
|
|
8
|
+
export declare function resolveEffort(entry: ModelEntry, effort: Effort | undefined): Effort | undefined;
|
|
2
9
|
/**
|
|
3
10
|
* Call an LLM with automatic failover to alternative providers.
|
|
4
11
|
*
|
package/dist/llm/complete.js
CHANGED
|
@@ -1,9 +1,25 @@
|
|
|
1
|
-
import { LLMError } from './types.js';
|
|
1
|
+
import { LLMError, EFFORT_LEVELS } from './types.js';
|
|
2
2
|
import { MODEL_REGISTRY, THINKING_HEADROOM_WARN_TOKENS } from './models.js';
|
|
3
3
|
import { providers } from './providers/index.js';
|
|
4
4
|
const DEFAULT_MAX_TOKENS = 2048;
|
|
5
5
|
const DEFAULT_TEMPERATURE = 0.3;
|
|
6
6
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
7
|
+
const DEFAULT_THINKING_LEVELS = ['low', 'medium', 'high'];
|
|
8
|
+
/**
|
|
9
|
+
* The effort level to send a model: none when the model has no effort
|
|
10
|
+
* control, otherwise the requested level clamped to the nearest one the
|
|
11
|
+
* model accepts (e.g. `minimal` → `low` on models without a `minimal`).
|
|
12
|
+
*/
|
|
13
|
+
export function resolveEffort(entry, effort) {
|
|
14
|
+
if (!entry.supportsEffort || effort === undefined)
|
|
15
|
+
return undefined;
|
|
16
|
+
const levels = entry.thinkingLevels ?? DEFAULT_THINKING_LEVELS;
|
|
17
|
+
if (levels.includes(effort))
|
|
18
|
+
return effort;
|
|
19
|
+
const rank = (level) => EFFORT_LEVELS.indexOf(level);
|
|
20
|
+
const wanted = rank(effort);
|
|
21
|
+
return [...levels].sort((a, b) => Math.abs(rank(a) - wanted) - Math.abs(rank(b) - wanted))[0];
|
|
22
|
+
}
|
|
7
23
|
/**
|
|
8
24
|
* Call an LLM with automatic failover to alternative providers.
|
|
9
25
|
*
|
|
@@ -42,6 +58,16 @@ export async function llmComplete(request) {
|
|
|
42
58
|
...(request.callSite ? { callSite: request.callSite } : {}),
|
|
43
59
|
});
|
|
44
60
|
}
|
|
61
|
+
// Google is retiring the Gemini thinking budget in favor of levels (see
|
|
62
|
+
// LLMRequest.thinkingBudget). Flag every caller still passing one so the
|
|
63
|
+
// migration to `effort` is visible in the logs, not discovered by a 400.
|
|
64
|
+
if (request.thinkingBudget !== undefined) {
|
|
65
|
+
request.logger?.warn('llm.thinking_budget_deprecated', {
|
|
66
|
+
model: request.model,
|
|
67
|
+
thinkingBudget: request.thinkingBudget,
|
|
68
|
+
...(request.callSite ? { callSite: request.callSite } : {}),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
45
71
|
// Build the ordered list of models to try: primary + fallbacks
|
|
46
72
|
const modelsToTry = [request.model, ...entry.fallbacks];
|
|
47
73
|
const failedAttempts = [];
|
|
@@ -67,7 +93,7 @@ export async function llmComplete(request) {
|
|
|
67
93
|
supportsTemperature: modelEntry.supportsTemperature ?? true,
|
|
68
94
|
supportsThinkingBudgetZero: modelEntry.supportsThinkingBudgetZero ?? true,
|
|
69
95
|
supportsForcedToolChoice: modelEntry.supportsForcedToolChoice ?? true,
|
|
70
|
-
effort: modelEntry
|
|
96
|
+
effort: resolveEffort(modelEntry, request.effort),
|
|
71
97
|
jsonSchema: request.jsonSchema,
|
|
72
98
|
apiKey,
|
|
73
99
|
logger: request.logger,
|
package/dist/llm/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { llmComplete } from './complete.js';
|
|
2
2
|
export { MODEL_REGISTRY } from './models.js';
|
|
3
3
|
export type { ModelEntry } from './models.js';
|
|
4
|
-
export { LLMError, type LLMRequest, type LLMResponse, type FailedAttempt, type FinishCategory, type ProviderName, type ApiKeys, type JsonSchema, } from './types.js';
|
|
4
|
+
export { LLMError, EFFORT_LEVELS, type Effort, type LLMRequest, type LLMResponse, type FailedAttempt, type FinishCategory, type ProviderName, type ApiKeys, type JsonSchema, } from './types.js';
|
package/dist/llm/index.js
CHANGED
package/dist/llm/models.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ProviderName } from './types.js';
|
|
1
|
+
import type { ProviderName, Effort } from './types.js';
|
|
2
2
|
export interface ModelEntry {
|
|
3
3
|
/** Provider that serves this model. */
|
|
4
4
|
provider: ProviderName;
|
|
@@ -22,12 +22,22 @@ export interface ModelEntry {
|
|
|
22
22
|
*/
|
|
23
23
|
supportsThinkingBudgetZero?: boolean;
|
|
24
24
|
/**
|
|
25
|
-
* Whether this model accepts Anthropic's
|
|
26
|
-
* (verified on Sonnet 5 / Sonnet 4.6 2026-08-10;
|
|
27
|
-
* 4.6+/5)
|
|
28
|
-
*
|
|
25
|
+
* Whether this model accepts a reasoning-effort level: Anthropic's
|
|
26
|
+
* `output_config.effort` (verified on Sonnet 5 / Sonnet 4.6 2026-08-10;
|
|
27
|
+
* documented GA on Opus 4.6+/5) or Gemini's `thinkingConfig.thinkingLevel`
|
|
28
|
+
* (every Gemini 3.x model; verified live 2026-10-06). Defaults to `false`;
|
|
29
|
+
* the effort option is dropped for models without it.
|
|
29
30
|
*/
|
|
30
31
|
supportsEffort?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* The effort levels this model accepts, for `supportsEffort` models whose
|
|
34
|
+
* set differs from the default `low | medium | high`. A requested level
|
|
35
|
+
* outside the set is clamped to the nearest one in it (`minimal` → `low`).
|
|
36
|
+
* Gemini 3.5 Flash-Lite, 3.6 Flash and 3 Flash preview also take `minimal`
|
|
37
|
+
* ("no hidden reasoning"); 3.1 Pro preview does not (Google's per-model
|
|
38
|
+
* table, 2026-10-06).
|
|
39
|
+
*/
|
|
40
|
+
thinkingLevels?: readonly Effort[];
|
|
31
41
|
/**
|
|
32
42
|
* Whether this model performs hidden reasoning when the request omits any
|
|
33
43
|
* thinking configuration (which this library always does). Defaults to
|
package/dist/llm/models.js
CHANGED
|
@@ -11,7 +11,12 @@ export const THINKING_HEADROOM_WARN_TOKENS = 4096;
|
|
|
11
11
|
export const MODEL_REGISTRY = {
|
|
12
12
|
// 2026-08 suite refresh: current models are claude-sonnet-5 / claude-opus-5
|
|
13
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
|
-
// 3.5/3.6 GA line plus gemini-3.1-pro-preview (Google).
|
|
14
|
+
// 3.5/3.6 GA line plus gemini-3.1-pro-preview (Google). Google's 2026-10
|
|
15
|
+
// deprecation notice: `temperature`/`topP`/`topK` have had no effect since
|
|
16
|
+
// 3.6 Flash and, like `thinkingBudget`, will 400 on models after 3.8 Flash
|
|
17
|
+
// — hence `supportsTemperature: false` from 3.6 Flash on and `effort`
|
|
18
|
+
// (thinking level) as the reasoning control on every Gemini 3.x entry.
|
|
19
|
+
// Older names stay
|
|
15
20
|
// registered so existing callers keep working, but no fallback chain routes
|
|
16
21
|
// through a retiring model: gemini-2.5-pro and gemini-2.5-flash shut down
|
|
17
22
|
// 2026-10-16, and the gpt-4o snapshots retire from 2026-10 onward.
|
|
@@ -30,6 +35,8 @@ export const MODEL_REGISTRY = {
|
|
|
30
35
|
// so no `thinksByDefault` headroom warning.
|
|
31
36
|
// Rejects thinkingBudget 0 (400, verified 2026-08-09).
|
|
32
37
|
supportsThinkingBudgetZero: false,
|
|
38
|
+
supportsEffort: true,
|
|
39
|
+
thinkingLevels: ['minimal', 'low', 'medium', 'high'],
|
|
33
40
|
},
|
|
34
41
|
'gemini-3.6-flash': {
|
|
35
42
|
provider: 'gemini',
|
|
@@ -40,6 +47,11 @@ export const MODEL_REGISTRY = {
|
|
|
40
47
|
thinksByDefault: true,
|
|
41
48
|
// Rejects thinkingBudget 0 (400, verified 2026-08-09).
|
|
42
49
|
supportsThinkingBudgetZero: false,
|
|
50
|
+
supportsEffort: true,
|
|
51
|
+
thinkingLevels: ['minimal', 'low', 'medium', 'high'],
|
|
52
|
+
// Sampling parameters are fixed at their defaults from this model on
|
|
53
|
+
// (Google, 2026-10); still accepted today, so omitting changes nothing.
|
|
54
|
+
supportsTemperature: false,
|
|
43
55
|
},
|
|
44
56
|
'gpt-5.6-luna': {
|
|
45
57
|
provider: 'openai',
|
|
@@ -63,11 +75,14 @@ export const MODEL_REGISTRY = {
|
|
|
63
75
|
provider: 'gemini',
|
|
64
76
|
apiModelId: 'gemini-3.1-flash-lite',
|
|
65
77
|
fallbacks: ['claude-sonnet-4-6'],
|
|
78
|
+
supportsEffort: true,
|
|
66
79
|
},
|
|
67
80
|
'gemini-3-flash-preview': {
|
|
68
81
|
provider: 'gemini',
|
|
69
82
|
apiModelId: 'gemini-3-flash-preview',
|
|
70
83
|
fallbacks: ['claude-haiku-4-5', 'gpt-5.6-luna'],
|
|
84
|
+
supportsEffort: true,
|
|
85
|
+
thinkingLevels: ['minimal', 'low', 'medium', 'high'],
|
|
71
86
|
},
|
|
72
87
|
'gpt-4o-mini': {
|
|
73
88
|
provider: 'openai',
|
|
@@ -181,6 +196,8 @@ export const MODEL_REGISTRY = {
|
|
|
181
196
|
thinksByDefault: true,
|
|
182
197
|
// Rejects thinkingBudget 0 (400, verified 2026-08-09).
|
|
183
198
|
supportsThinkingBudgetZero: false,
|
|
199
|
+
// Levels low|medium|high only (no `minimal`) — the default set.
|
|
200
|
+
supportsEffort: true,
|
|
184
201
|
},
|
|
185
202
|
// Retiring 2026-10-16 — migrate callers to gemini-3.1-pro-preview.
|
|
186
203
|
'gemini-2.5-pro': {
|
|
@@ -53,13 +53,18 @@ export const geminiProvider = {
|
|
|
53
53
|
generationConfig.responseSchema = stripUnsupportedSchemaFields(request.jsonSchema.schema);
|
|
54
54
|
}
|
|
55
55
|
// Gemini thinking models reason before answering; the reasoning tokens
|
|
56
|
-
// count against maxOutputTokens.
|
|
57
|
-
//
|
|
58
|
-
//
|
|
56
|
+
// count against maxOutputTokens. `effort` (pre-gated and clamped in
|
|
57
|
+
// complete.ts) is the current control, sent as a thinking level — not a
|
|
58
|
+
// token cap, so callers size maxOutputTokens for it. The deprecated
|
|
59
|
+
// thinkingBudget is still forwarded when no level is set (the API rejects
|
|
60
|
+
// both together). Models with a thinking floor (registry
|
|
59
61
|
// `supportsThinkingBudgetZero: false`) reject an explicit 0 budget with a
|
|
60
62
|
// 400 — for those a 0 means "as little thinking as possible", which their
|
|
61
63
|
// default already is, so the config is omitted entirely.
|
|
62
|
-
if (request.
|
|
64
|
+
if (request.effort) {
|
|
65
|
+
generationConfig.thinkingConfig = { thinkingLevel: request.effort };
|
|
66
|
+
}
|
|
67
|
+
else if (request.thinkingBudget !== undefined
|
|
63
68
|
&& (request.thinkingBudget !== 0 || request.supportsThinkingBudgetZero)) {
|
|
64
69
|
generationConfig.thinkingConfig = { thinkingBudget: request.thinkingBudget };
|
|
65
70
|
}
|
package/dist/llm/types.d.ts
CHANGED
|
@@ -35,21 +35,25 @@ export interface LLMRequest {
|
|
|
35
35
|
/** Timeout per provider attempt, including response body download, in ms. Default: 60000. */
|
|
36
36
|
timeoutMs?: number;
|
|
37
37
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
38
|
+
* @deprecated Use `effort`. Gemini's token cap on hidden reasoning. Gemini
|
|
39
|
+
* 3.x already remaps a budget to a thinking *level* (so it is no longer a
|
|
40
|
+
* cap), and Google has announced that models after 3.8 Flash reject it
|
|
41
|
+
* outright (400). Still forwarded to Gemini when `effort` is absent, with
|
|
42
|
+
* WARN `llm.thinking_budget_deprecated` per call; ignored by Anthropic and
|
|
43
|
+
* OpenAI, and ignored by Gemini when `effort` is also set.
|
|
44
44
|
*/
|
|
45
45
|
thinkingBudget?: number;
|
|
46
46
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
47
|
+
* Reasoning-effort level for models that expose one (registry
|
|
48
|
+
* `supportsEffort`): Anthropic `output_config.effort`, Gemini
|
|
49
|
+
* `thinkingConfig.thinkingLevel`. Lower effort trades thoroughness for
|
|
50
|
+
* latency and cost. A level the target model lacks is clamped to its
|
|
51
|
+
* nearest supported level (registry `thinkingLevels`; `minimal` → `low` on
|
|
52
|
+
* Anthropic). Dropped for models without the control, so a failed-over
|
|
53
|
+
* call runs at the fallback's defaults. Levels are not token caps: size
|
|
54
|
+
* `maxTokens` for the reasoning the level will do.
|
|
51
55
|
*/
|
|
52
|
-
effort?:
|
|
56
|
+
effort?: Effort;
|
|
53
57
|
/**
|
|
54
58
|
* Optional short identifier for the calling feature (e.g. 'video_classify',
|
|
55
59
|
* 'message_prioritize', 'log_summary'). Included in llm.request and
|
|
@@ -80,6 +84,14 @@ export interface LLMRequest {
|
|
|
80
84
|
* provider-specific vocabulary local to the adapter that owns it.
|
|
81
85
|
*/
|
|
82
86
|
export type FinishCategory = 'complete' | 'truncated' | 'declined' | 'other';
|
|
87
|
+
/**
|
|
88
|
+
* Reasoning-effort levels, ordered from least to most. Each provider adapter
|
|
89
|
+
* maps a level to its native control (Anthropic `output_config.effort`,
|
|
90
|
+
* Gemini `thinkingConfig.thinkingLevel`); `minimal` exists only on some
|
|
91
|
+
* Gemini models and means "no hidden reasoning".
|
|
92
|
+
*/
|
|
93
|
+
export declare const EFFORT_LEVELS: readonly ["minimal", "low", "medium", "high"];
|
|
94
|
+
export type Effort = (typeof EFFORT_LEVELS)[number];
|
|
83
95
|
/** Successful response from llmComplete(). */
|
|
84
96
|
export interface LLMResponse {
|
|
85
97
|
/** The text output (or JSON stringified if jsonSchema was used). */
|
|
@@ -188,8 +200,11 @@ export interface ProviderCallRequest {
|
|
|
188
200
|
logger?: Logger;
|
|
189
201
|
timeoutMs: number;
|
|
190
202
|
thinkingBudget?: number;
|
|
191
|
-
/**
|
|
192
|
-
|
|
203
|
+
/**
|
|
204
|
+
* Effort level, pre-gated by the registry's `supportsEffort` (absent when
|
|
205
|
+
* unsupported) and clamped to the model's `thinkingLevels`.
|
|
206
|
+
*/
|
|
207
|
+
effort?: Effort;
|
|
193
208
|
callSite?: string;
|
|
194
209
|
cacheSystemPrompt?: boolean;
|
|
195
210
|
}
|
package/dist/llm/types.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reasoning-effort levels, ordered from least to most. Each provider adapter
|
|
3
|
+
* maps a level to its native control (Anthropic `output_config.effort`,
|
|
4
|
+
* Gemini `thinkingConfig.thinkingLevel`); `minimal` exists only on some
|
|
5
|
+
* Gemini models and means "no hidden reasoning".
|
|
6
|
+
*/
|
|
7
|
+
export const EFFORT_LEVELS = ['minimal', 'low', 'medium', 'high'];
|
|
1
8
|
/** Error thrown when an LLM provider call fails. Its message may contain private input; do not log verbatim. */
|
|
2
9
|
export class LLMError extends Error {
|
|
3
10
|
provider;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steve31415/baselib",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.29.0",
|
|
4
4
|
"description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -80,6 +80,10 @@
|
|
|
80
80
|
"./email": {
|
|
81
81
|
"types": "./dist/email.d.ts",
|
|
82
82
|
"default": "./dist/email.js"
|
|
83
|
+
},
|
|
84
|
+
"./connector": {
|
|
85
|
+
"types": "./dist/connector.d.ts",
|
|
86
|
+
"default": "./dist/connector.js"
|
|
83
87
|
}
|
|
84
88
|
},
|
|
85
89
|
"bin": {
|
|
@@ -104,10 +108,12 @@
|
|
|
104
108
|
"dependencies": {
|
|
105
109
|
"@google-cloud/cloud-sql-connector": "^1.8.2",
|
|
106
110
|
"@hono/node-server": "^1.14.0",
|
|
111
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
107
112
|
"google-auth-library": "^10.5.0",
|
|
108
113
|
"hono": "^4.7.0",
|
|
109
114
|
"pg": "^8.14.0",
|
|
110
|
-
"web-vitals": "^6.1.1"
|
|
115
|
+
"web-vitals": "^6.1.1",
|
|
116
|
+
"zod": "^4.6.5"
|
|
111
117
|
},
|
|
112
118
|
"devDependencies": {
|
|
113
119
|
"@types/node": "^22.0.0",
|