@superjackfruitlabs/salaam 0.0.1 → 0.0.2
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/CHANGELOG.md +15 -0
- package/README.md +25 -17
- package/dist/src/a2a-backend.js +225 -0
- package/dist/src/a2a-inbound.js +487 -0
- package/dist/src/a2a-peer.js +370 -0
- package/dist/src/a2a-protocol.js +250 -0
- package/dist/src/agent-profiles.js +289 -0
- package/dist/src/children.js +132 -26
- package/dist/src/claude-provider.js +2 -179
- package/dist/src/cli-help.js +107 -6
- package/dist/src/cli.js +477 -13
- package/dist/src/config.js +26 -3
- package/dist/src/credentials.js +7 -2
- package/dist/src/external-runtime.js +1 -0
- package/dist/src/external-work.js +583 -0
- package/dist/src/harness-backend.js +466 -0
- package/dist/src/harness-container.js +450 -0
- package/dist/src/harness-model-gateway.js +104 -0
- package/dist/src/harness-prepare.js +88 -0
- package/dist/src/harness-probe.js +111 -0
- package/dist/src/harness-protocol.js +325 -0
- package/dist/src/harness-readiness.js +50 -0
- package/dist/src/harness-rpc.js +206 -0
- package/dist/src/harness-tool-identity.js +87 -0
- package/dist/src/harness-wire-bridge.js +387 -0
- package/dist/src/host.js +6 -1
- package/dist/src/house-extension.js +147 -64
- package/dist/src/live-provider.js +301 -0
- package/dist/src/managed.js +33 -3
- package/dist/src/protocol.js +9 -5
- package/dist/src/provider-auth.js +300 -0
- package/dist/src/provider-config.js +65 -17
- package/dist/src/provider-model.js +78 -0
- package/dist/src/provider-payload.js +264 -0
- package/dist/src/provider-transport.js +36 -0
- package/dist/src/provider-usage.js +59 -0
- package/dist/src/repository-snapshots.js +219 -0
- package/dist/src/service.js +274 -9
- package/dist/src/store.js +44 -2
- package/dist/src/supervisor.js +246 -35
- package/dist/src/task-budget.js +17 -8
- package/dist/src/task-context.js +15 -0
- package/dist/src/task-driver.js +38 -14
- package/dist/src/task-policy.js +20 -1
- package/dist/src/task-verification.js +3 -0
- package/dist/src/task-work.js +31 -0
- package/dist/src/team-work.js +378 -0
- package/dist/src/tui-view.js +65 -10
- package/dist/src/tui.js +4 -2
- package/dist/src/usage.js +18 -0
- package/dist/src/worker-boundary.js +298 -0
- package/dist/src/worker-workspaces.js +274 -0
- package/dist/src/workspaces.js +6 -2
- package/harness-runtime/codex-pins.json +10 -0
- package/harness-runtime/package-lock.json +560 -0
- package/harness-runtime/package.json +9 -0
- package/npm-shrinkwrap.json +57 -2
- package/package.json +2 -1
- package/src/a2a-backend.ts +254 -0
- package/src/a2a-inbound.ts +585 -0
- package/src/a2a-peer.ts +441 -0
- package/src/a2a-protocol.ts +286 -0
- package/src/agent-profiles.ts +379 -0
- package/src/children.ts +168 -27
- package/src/claude-provider.ts +2 -189
- package/src/cli-help.ts +107 -6
- package/src/cli.ts +602 -12
- package/src/config.ts +34 -3
- package/src/credentials.ts +9 -2
- package/src/external-runtime.ts +54 -0
- package/src/external-work.ts +663 -0
- package/src/harness-backend.ts +557 -0
- package/src/harness-container.ts +543 -0
- package/src/harness-model-gateway.ts +138 -0
- package/src/harness-prepare.ts +113 -0
- package/src/harness-probe.ts +150 -0
- package/src/harness-protocol.ts +388 -0
- package/src/harness-readiness.ts +71 -0
- package/src/harness-rpc.ts +244 -0
- package/src/harness-tool-identity.ts +91 -0
- package/src/harness-wire-bridge.ts +418 -0
- package/src/host.ts +11 -6
- package/src/house-extension.ts +165 -66
- package/src/live-provider.ts +347 -0
- package/src/managed.ts +43 -4
- package/src/protocol.ts +36 -8
- package/src/provider-auth.ts +336 -0
- package/src/provider-config.ts +89 -18
- package/src/provider-model.ts +86 -0
- package/src/provider-payload.ts +263 -0
- package/src/provider-transport.ts +51 -0
- package/src/provider-usage.ts +64 -0
- package/src/repository-snapshots.ts +259 -0
- package/src/service.ts +446 -11
- package/src/store.ts +60 -4
- package/src/supervisor.ts +294 -41
- package/src/task-budget.ts +24 -11
- package/src/task-context.ts +17 -0
- package/src/task-driver.ts +51 -15
- package/src/task-policy.ts +23 -3
- package/src/task-verification.ts +2 -0
- package/src/task-work.ts +46 -1
- package/src/team-work.ts +554 -0
- package/src/tui-client.ts +5 -0
- package/src/tui-view.ts +164 -12
- package/src/tui.ts +5 -2
- package/src/usage.ts +17 -0
- package/src/worker-boundary.ts +395 -0
- package/src/worker-workspaces.ts +390 -0
- package/src/workspaces.ts +4 -2
package/dist/src/managed.js
CHANGED
|
@@ -122,7 +122,11 @@ export class ManagedWork {
|
|
|
122
122
|
const connector = this.options.connector;
|
|
123
123
|
if (kind === 'effect' && !connector)
|
|
124
124
|
throw new ServiceError('effect_disabled', 'No external effect connector is configured');
|
|
125
|
-
const version = kind === '
|
|
125
|
+
const version = kind === 'harness'
|
|
126
|
+
? 'owned-once-only'
|
|
127
|
+
: kind === 'review'
|
|
128
|
+
? 'read-only'
|
|
129
|
+
: await connector.version(target, AbortSignal.timeout(2500));
|
|
126
130
|
return this.store.transaction(() => {
|
|
127
131
|
this.options.assertActive(session, epoch, attempt);
|
|
128
132
|
const existing = this.store.db
|
|
@@ -152,8 +156,12 @@ export class ManagedWork {
|
|
|
152
156
|
value,
|
|
153
157
|
target_version: version,
|
|
154
158
|
policy_digest: POLICY_DIGEST,
|
|
155
|
-
connector: kind === '
|
|
156
|
-
|
|
159
|
+
connector: kind === 'harness'
|
|
160
|
+
? 'external-harness-once-v1'
|
|
161
|
+
: kind === 'review'
|
|
162
|
+
? 'local-review-v1'
|
|
163
|
+
: connector.id,
|
|
164
|
+
replay: kind === 'harness' ? 'opaque' : kind === 'review' ? 'read-only' : connector.replay,
|
|
157
165
|
revision: 1,
|
|
158
166
|
state: 'prepared',
|
|
159
167
|
receipt: null,
|
|
@@ -299,6 +307,8 @@ export class ManagedWork {
|
|
|
299
307
|
}
|
|
300
308
|
start(id, session, epoch, attempt) {
|
|
301
309
|
const op = this.operation(id);
|
|
310
|
+
if (op.kind === 'harness')
|
|
311
|
+
throw new ServiceError('operation_scope', 'A harness permission can only be consumed by its owned runtime');
|
|
302
312
|
if (op.session_id !== session || op.worker_epoch !== epoch || op.attempt_id !== attempt)
|
|
303
313
|
throw new ServiceError('operation_scope', 'Operation belongs to another invocation', 403);
|
|
304
314
|
if (op.state !== 'prepared' || this.active.has(id))
|
|
@@ -322,6 +332,26 @@ export class ManagedWork {
|
|
|
322
332
|
argument_digest: op.argument_digest,
|
|
323
333
|
};
|
|
324
334
|
}
|
|
335
|
+
/** Commit a one-time grant handoff before replying to the owned runtime. This is not a tool receipt. */
|
|
336
|
+
takePermission(id) {
|
|
337
|
+
return this.store.transaction(() => {
|
|
338
|
+
const op = this.operation(id);
|
|
339
|
+
if (op.kind !== 'harness' || op.state !== 'prepared')
|
|
340
|
+
return false;
|
|
341
|
+
this.fresh(op);
|
|
342
|
+
const approval = this.forOperation(id);
|
|
343
|
+
if (approval.state !== 'decided' || approval.decision !== 'approve')
|
|
344
|
+
return false;
|
|
345
|
+
this.transition(op, 'dispatching');
|
|
346
|
+
return true;
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
permissionResult(id, success, receipt) {
|
|
350
|
+
const op = this.operation(id);
|
|
351
|
+
if (op.kind !== 'harness' || op.state !== 'dispatching')
|
|
352
|
+
return;
|
|
353
|
+
this.store.transaction(() => this.transition(op, success ? 'succeeded' : 'outcome_unknown', success ? null : 'Harness tool failed after permission delivery; inspect actual effects', JSON.stringify(receipt), 'owned-harness-tool-return'));
|
|
354
|
+
}
|
|
325
355
|
async dispatch(id, signal) {
|
|
326
356
|
try {
|
|
327
357
|
let op = this.operation(id);
|
package/dist/src/protocol.js
CHANGED
|
@@ -8,6 +8,7 @@ export const CAPABILITIES = [
|
|
|
8
8
|
'durable-approvals',
|
|
9
9
|
'operation-ledger',
|
|
10
10
|
'child-work',
|
|
11
|
+
'worker-boundary-v1',
|
|
11
12
|
'usage-ledger',
|
|
12
13
|
'durable-task-context',
|
|
13
14
|
'managed-compaction',
|
|
@@ -20,20 +21,23 @@ export const TOOLS = [
|
|
|
20
21
|
'consume_child_result',
|
|
21
22
|
'salaam_history',
|
|
22
23
|
];
|
|
23
|
-
export function workerCapabilities(coding) {
|
|
24
|
+
export function workerCapabilities(coding, childTools) {
|
|
24
25
|
return [
|
|
25
26
|
...CAPABILITIES,
|
|
26
|
-
...(coding ? ['workspace-files'] : []),
|
|
27
|
+
...(coding?.files ? ['workspace-files'] : []),
|
|
27
28
|
...(coding?.commands ? ['command-execution'] : []),
|
|
29
|
+
...(childTools?.includes('salaam_repository') ? ['repository-snapshot-read'] : []),
|
|
30
|
+
...(childTools?.includes('salaam_findings') ? ['structured-findings'] : []),
|
|
31
|
+
...(childTools?.includes('salaam_team') ? ['durable-team-messages'] : []),
|
|
28
32
|
];
|
|
29
33
|
}
|
|
30
|
-
export function workerTools(effects, child = false, coding) {
|
|
34
|
+
export function workerTools(effects, child = false, coding, childTools) {
|
|
31
35
|
return child
|
|
32
|
-
? ['salaam_probe']
|
|
36
|
+
? [...(childTools ?? ['salaam_probe'])].sort()
|
|
33
37
|
: [
|
|
34
38
|
...TOOLS,
|
|
35
39
|
...(effects ? ['salaam_effect'] : []),
|
|
36
|
-
...(coding ? ['salaam_files'] : []),
|
|
40
|
+
...(coding?.files ? ['salaam_files'] : []),
|
|
37
41
|
...(coding?.commands ? ['salaam_command'] : []),
|
|
38
42
|
].sort();
|
|
39
43
|
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { builtinModels } from '@earendil-works/pi-ai/providers/all';
|
|
2
|
+
import { defaultProviderAuthContext, } from '@earendil-works/pi-ai';
|
|
3
|
+
import { loadSharedConfigFiles } from '@smithy/core/config';
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
+
import { existsSync, lstatSync, mkdirSync, chmodSync, openSync, closeSync, constants, fstatSync, readFileSync, writeFileSync, fsyncSync, renameSync, unlinkSync, } from 'node:fs';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { credentialPath, loadApiKey, validateApiKey } from './credentials.js';
|
|
10
|
+
import { getProvider, getProviderModel } from './provider-config.js';
|
|
11
|
+
export const authDirectory = () => join(homedir(), '.config', 'salaam', 'credentials');
|
|
12
|
+
function providerId(id) {
|
|
13
|
+
if (!getProvider(id) || !/^[a-z0-9-]+$/.test(id))
|
|
14
|
+
throw new Error('Unknown Pi provider');
|
|
15
|
+
return id;
|
|
16
|
+
}
|
|
17
|
+
function secureDirectory(dir) {
|
|
18
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
19
|
+
const stat = lstatSync(dir);
|
|
20
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.())
|
|
21
|
+
throw new Error('Unsafe credential directory');
|
|
22
|
+
chmodSync(dir, 0o700);
|
|
23
|
+
}
|
|
24
|
+
function privateRead(path) {
|
|
25
|
+
let fd;
|
|
26
|
+
try {
|
|
27
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (error.code === 'ENOENT')
|
|
31
|
+
return;
|
|
32
|
+
throw new Error('Unsafe provider credential file');
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const stat = fstatSync(fd);
|
|
36
|
+
if (!stat.isFile() || stat.uid !== process.getuid?.() || stat.mode & 0o077 || stat.size > 65536)
|
|
37
|
+
throw new Error('Provider credential file must be private (mode 600)');
|
|
38
|
+
return readFileSync(fd, 'utf8');
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
closeSync(fd);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Serialize OAuth refresh across daemon and CLI processes; OS locks release on death. */
|
|
45
|
+
export class ProviderCredentialStore {
|
|
46
|
+
dir;
|
|
47
|
+
constructor(dir = authDirectory()) {
|
|
48
|
+
this.dir = dir;
|
|
49
|
+
}
|
|
50
|
+
path(id) {
|
|
51
|
+
return join(this.dir, providerId(id) + '.json');
|
|
52
|
+
}
|
|
53
|
+
async read(id, options) {
|
|
54
|
+
options?.signal?.throwIfAborted();
|
|
55
|
+
const raw = privateRead(this.path(id));
|
|
56
|
+
if (raw !== undefined) {
|
|
57
|
+
const value = JSON.parse(raw);
|
|
58
|
+
if (!['api_key', 'oauth'].includes(value.type))
|
|
59
|
+
throw new Error('Invalid stored provider credential');
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
// Preserve existing Salaam installs without reading Pi's credential home.
|
|
63
|
+
if (id === 'anthropic' && this.dir === authDirectory() && existsSync(credentialPath()))
|
|
64
|
+
return { type: 'api_key', key: loadApiKey(credentialPath(), '') };
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
async list(options) {
|
|
68
|
+
const result = [];
|
|
69
|
+
for (const p of builtinModels().getProviders()) {
|
|
70
|
+
const value = await this.read(p.id, options);
|
|
71
|
+
if (value)
|
|
72
|
+
result.push({ providerId: p.id, type: value.type });
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
async locked(id, fn, options) {
|
|
77
|
+
secureDirectory(this.dir);
|
|
78
|
+
const path = join(this.dir, providerId(id) + '.lock.sqlite');
|
|
79
|
+
if (existsSync(path))
|
|
80
|
+
privateRead(path);
|
|
81
|
+
const fd = openSync(path, constants.O_RDWR | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
|
|
82
|
+
closeSync(fd);
|
|
83
|
+
const db = new DatabaseSync(path);
|
|
84
|
+
const deadline = Date.now() + 120000;
|
|
85
|
+
try {
|
|
86
|
+
db.exec('PRAGMA busy_timeout=0');
|
|
87
|
+
while (true) {
|
|
88
|
+
options?.signal?.throwIfAborted();
|
|
89
|
+
try {
|
|
90
|
+
db.exec('BEGIN EXCLUSIVE');
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (!String(error).includes('locked') || Date.now() > deadline)
|
|
95
|
+
throw new Error('Provider credential lock unavailable');
|
|
96
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
return await fn();
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
db.exec('ROLLBACK');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
db.close();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async modify(id, fn, options) {
|
|
111
|
+
return this.locked(id, async () => {
|
|
112
|
+
const current = await this.read(id, options), value = await fn(current);
|
|
113
|
+
options?.signal?.throwIfAborted();
|
|
114
|
+
if (value !== undefined) {
|
|
115
|
+
const temp = join(this.dir, '.' + id + '-' + randomUUID()), fd = openSync(temp, 'wx', 0o600);
|
|
116
|
+
try {
|
|
117
|
+
writeFileSync(fd, JSON.stringify(value));
|
|
118
|
+
fsyncSync(fd);
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
closeSync(fd);
|
|
122
|
+
}
|
|
123
|
+
renameSync(temp, this.path(id));
|
|
124
|
+
const directory = openSync(this.dir, 'r');
|
|
125
|
+
try {
|
|
126
|
+
fsyncSync(directory);
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
closeSync(directory);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return value ?? current;
|
|
133
|
+
}, options);
|
|
134
|
+
}
|
|
135
|
+
async delete(id, options) {
|
|
136
|
+
await this.locked(id, async () => {
|
|
137
|
+
for (const path of [
|
|
138
|
+
this.path(id),
|
|
139
|
+
...(id === 'anthropic' && this.dir === authDirectory() ? [credentialPath()] : []),
|
|
140
|
+
])
|
|
141
|
+
try {
|
|
142
|
+
unlinkSync(path);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (error.code !== 'ENOENT')
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}, options);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
export function providerModels(store = new ProviderCredentialStore()) {
|
|
152
|
+
return builtinModels({ credentials: store });
|
|
153
|
+
}
|
|
154
|
+
export function providerDeviceId(dir = authDirectory()) {
|
|
155
|
+
secureDirectory(dir);
|
|
156
|
+
const path = join(dir, '.device-id');
|
|
157
|
+
let value = privateRead(path);
|
|
158
|
+
if (value === undefined) {
|
|
159
|
+
const id = randomUUID();
|
|
160
|
+
try {
|
|
161
|
+
const fd = openSync(path, 'wx', 0o600);
|
|
162
|
+
try {
|
|
163
|
+
writeFileSync(fd, id);
|
|
164
|
+
fsyncSync(fd);
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
closeSync(fd);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
if (error.code !== 'EEXIST')
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
value = privateRead(path);
|
|
175
|
+
}
|
|
176
|
+
if (!value || !/^[a-f0-9-]{36}$/.test(value))
|
|
177
|
+
throw new Error('Invalid provider device identity');
|
|
178
|
+
return value;
|
|
179
|
+
}
|
|
180
|
+
export async function loginProvider(id, type, interaction) {
|
|
181
|
+
providerId(id);
|
|
182
|
+
try {
|
|
183
|
+
return await providerModels().login(id, type, interaction, {
|
|
184
|
+
agentName: 'Salaam',
|
|
185
|
+
getDeviceId: () => providerDeviceId(),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
throw new Error('Provider login failed or was cancelled; retry salaam auth login ' + id);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// These are SDK configuration fields, never a general process environment inheritance.
|
|
193
|
+
const sdkEnvironment = {
|
|
194
|
+
'amazon-bedrock': [
|
|
195
|
+
'AWS_REGION',
|
|
196
|
+
'AWS_DEFAULT_REGION',
|
|
197
|
+
'AWS_PROFILE',
|
|
198
|
+
'AWS_ACCESS_KEY_ID',
|
|
199
|
+
'AWS_SECRET_ACCESS_KEY',
|
|
200
|
+
'AWS_SESSION_TOKEN',
|
|
201
|
+
'AWS_BEARER_TOKEN_BEDROCK',
|
|
202
|
+
'AWS_SHARED_CREDENTIALS_FILE',
|
|
203
|
+
'AWS_CONFIG_FILE',
|
|
204
|
+
'AWS_WEB_IDENTITY_TOKEN_FILE',
|
|
205
|
+
'AWS_ROLE_ARN',
|
|
206
|
+
'AWS_ROLE_SESSION_NAME',
|
|
207
|
+
'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI',
|
|
208
|
+
'AWS_CONTAINER_CREDENTIALS_FULL_URI',
|
|
209
|
+
'AWS_CONTAINER_AUTHORIZATION_TOKEN',
|
|
210
|
+
'AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE',
|
|
211
|
+
],
|
|
212
|
+
'google-vertex': [
|
|
213
|
+
'GOOGLE_CLOUD_PROJECT',
|
|
214
|
+
'GCLOUD_PROJECT',
|
|
215
|
+
'GOOGLE_CLOUD_LOCATION',
|
|
216
|
+
'GOOGLE_APPLICATION_CREDENTIALS',
|
|
217
|
+
'GOOGLE_CLOUD_API_KEY',
|
|
218
|
+
],
|
|
219
|
+
azure: [
|
|
220
|
+
'AZURE_OPENAI_BASE_URL',
|
|
221
|
+
'AZURE_OPENAI_RESOURCE_NAME',
|
|
222
|
+
'AZURE_OPENAI_API_VERSION',
|
|
223
|
+
'AZURE_OPENAI_DEPLOYMENT_NAME_MAP',
|
|
224
|
+
],
|
|
225
|
+
};
|
|
226
|
+
export async function resolveProviderAuth(route, store, environment = process.env) {
|
|
227
|
+
if (!store && route.provider === 'anthropic' && environment.ANTHROPIC_API_KEY)
|
|
228
|
+
return { auth: { apiKey: validateApiKey(environment.ANTHROPIC_API_KEY) }, env: {} };
|
|
229
|
+
const model = getProviderModel(route);
|
|
230
|
+
if (!model)
|
|
231
|
+
throw new Error('Pinned provider model missing');
|
|
232
|
+
const baseContext = defaultProviderAuthContext();
|
|
233
|
+
const ambient = {
|
|
234
|
+
...baseContext,
|
|
235
|
+
env: async (name) => environment[name]?.trim() || undefined,
|
|
236
|
+
};
|
|
237
|
+
const context = {
|
|
238
|
+
...ambient,
|
|
239
|
+
env: async (name) => (await ambient.env(name)) ?? route.connection?.configuration[name],
|
|
240
|
+
}, captured = {};
|
|
241
|
+
const models = builtinModels({
|
|
242
|
+
credentials: store ?? new ProviderCredentialStore(),
|
|
243
|
+
authContext: {
|
|
244
|
+
...context,
|
|
245
|
+
async env(name) {
|
|
246
|
+
const value = await context.env(name);
|
|
247
|
+
if (value !== undefined)
|
|
248
|
+
captured[name] = value;
|
|
249
|
+
return value;
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
let result;
|
|
254
|
+
try {
|
|
255
|
+
result = await models.getAuth(model);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
throw new Error('Provider authentication failed; run salaam auth login ' + route.provider);
|
|
259
|
+
}
|
|
260
|
+
if (!result)
|
|
261
|
+
throw new Error('Provider credentials missing. Run: salaam auth login ' + route.provider);
|
|
262
|
+
for (const name of sdkEnvironment[route.provider] ?? [])
|
|
263
|
+
if (!(route.provider === 'amazon-bedrock' &&
|
|
264
|
+
result.auth.apiKey &&
|
|
265
|
+
![
|
|
266
|
+
'AWS_REGION',
|
|
267
|
+
'AWS_DEFAULT_REGION',
|
|
268
|
+
'AWS_PROFILE',
|
|
269
|
+
'AWS_CONFIG_FILE',
|
|
270
|
+
'AWS_SHARED_CREDENTIALS_FILE',
|
|
271
|
+
].includes(name)) &&
|
|
272
|
+
(await context.env(name)))
|
|
273
|
+
captured[name] = (await context.env(name));
|
|
274
|
+
if (route.provider === 'amazon-bedrock') {
|
|
275
|
+
captured.AWS_MAX_ATTEMPTS = '1';
|
|
276
|
+
captured.AWS_SHARED_CREDENTIALS_FILE ??= join(homedir(), '.aws', 'credentials');
|
|
277
|
+
captured.AWS_CONFIG_FILE ??= join(homedir(), '.aws', 'config');
|
|
278
|
+
const configured = { ...captured, ...result.env };
|
|
279
|
+
if (!configured.AWS_REGION && !configured.AWS_DEFAULT_REGION) {
|
|
280
|
+
const files = await loadSharedConfigFiles({
|
|
281
|
+
filepath: configured.AWS_SHARED_CREDENTIALS_FILE,
|
|
282
|
+
configFilepath: configured.AWS_CONFIG_FILE,
|
|
283
|
+
});
|
|
284
|
+
const profile = configured.AWS_PROFILE ?? 'default';
|
|
285
|
+
captured.AWS_REGION =
|
|
286
|
+
files.configFile[profile]?.region ?? files.credentialsFile[profile]?.region ?? 'us-east-1';
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (route.provider === 'google-vertex' && !result.auth.apiKey)
|
|
290
|
+
captured.GOOGLE_APPLICATION_CREDENTIALS ??= join(homedir(), '.config/gcloud/application_default_credentials.json');
|
|
291
|
+
const allowed = new Set([
|
|
292
|
+
...Object.keys(captured),
|
|
293
|
+
...(sdkEnvironment[route.provider] ?? []),
|
|
294
|
+
...(route.provider.startsWith('cloudflare-')
|
|
295
|
+
? ['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_GATEWAY_ID']
|
|
296
|
+
: []),
|
|
297
|
+
]);
|
|
298
|
+
const env = Object.fromEntries(Object.entries({ ...captured, ...result.env }).filter(([key]) => allowed.has(key)));
|
|
299
|
+
return { ...result, env };
|
|
300
|
+
}
|
|
@@ -1,37 +1,77 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { builtinProviders } from '@earendil-works/pi-ai/providers/all';
|
|
2
2
|
import { ServiceError, object, canonical } from './protocol.js';
|
|
3
|
+
// Constructing the pinned catalog is offline; never refresh it implicitly during a turn.
|
|
4
|
+
export const PI_PROVIDERS = builtinProviders();
|
|
5
|
+
export function getProvider(id) {
|
|
6
|
+
return PI_PROVIDERS.find((provider) => provider.id === id);
|
|
7
|
+
}
|
|
8
|
+
export function getProviderModel(route) {
|
|
9
|
+
return getProvider(route.provider)
|
|
10
|
+
?.getModels()
|
|
11
|
+
.find((model) => model.id === route.model);
|
|
12
|
+
}
|
|
3
13
|
export function getClaudeModel(id) {
|
|
4
|
-
return
|
|
5
|
-
.getModels()
|
|
6
|
-
.find((model) => model.id === id);
|
|
14
|
+
return getProviderModel({ provider: 'anthropic', model: id });
|
|
7
15
|
}
|
|
16
|
+
export const ROUTING_FIELDS = [
|
|
17
|
+
'AWS_REGION',
|
|
18
|
+
'AWS_DEFAULT_REGION',
|
|
19
|
+
'AWS_PROFILE',
|
|
20
|
+
'GOOGLE_CLOUD_PROJECT',
|
|
21
|
+
'GCLOUD_PROJECT',
|
|
22
|
+
'GOOGLE_CLOUD_LOCATION',
|
|
23
|
+
'AZURE_OPENAI_API_VERSION',
|
|
24
|
+
'AZURE_OPENAI_BASE_URL',
|
|
25
|
+
'AZURE_OPENAI_RESOURCE_NAME',
|
|
26
|
+
'AZURE_OPENAI_DEPLOYMENT_NAME_MAP',
|
|
27
|
+
'CLOUDFLARE_ACCOUNT_ID',
|
|
28
|
+
'CLOUDFLARE_GATEWAY_ID',
|
|
29
|
+
];
|
|
8
30
|
export const OFFLINE_ROUTE = {
|
|
9
31
|
provider: 'salaam-proof',
|
|
10
32
|
model: 'scripted',
|
|
11
33
|
max_tokens: 1024,
|
|
12
34
|
};
|
|
13
35
|
export const DEFAULT_CLAUDE_MODEL = 'claude-sonnet-5-5';
|
|
14
|
-
export function providerRoute(provider = 'offline', model, maxTokens, workspace) {
|
|
36
|
+
export function providerRoute(provider = 'offline', model, maxTokens, workspace, connection) {
|
|
15
37
|
if (provider === 'offline') {
|
|
16
|
-
if (model || maxTokens || workspace)
|
|
17
|
-
throw new ServiceError('invalid_provider', 'Offline mode does not take
|
|
38
|
+
if (model || maxTokens !== undefined || workspace)
|
|
39
|
+
throw new ServiceError('invalid_provider', 'Offline mode does not take provider options');
|
|
18
40
|
return { ...OFFLINE_ROUTE };
|
|
19
41
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
42
|
+
const catalog = getProvider(provider);
|
|
43
|
+
if (!catalog)
|
|
44
|
+
throw new ServiceError('invalid_provider', 'Provider must be offline or a pinned Pi provider; run salaam providers list');
|
|
45
|
+
const id = model ?? (provider === 'anthropic' ? DEFAULT_CLAUDE_MODEL : undefined);
|
|
46
|
+
if (!id)
|
|
47
|
+
throw new ServiceError('invalid_model', 'Select --model from salaam models list ' + provider);
|
|
48
|
+
const entry = getProviderModel({ provider, model: id });
|
|
49
|
+
if (!entry)
|
|
50
|
+
throw new ServiceError('invalid_model', 'This route supports pinned Pi chat models; run salaam models list ' + provider);
|
|
51
|
+
const cap = maxTokens ?? Math.min(4096, entry.maxTokens);
|
|
52
|
+
if (!Number.isSafeInteger(cap) || cap < 128 || cap > Math.min(8192, entry.maxTokens))
|
|
53
|
+
throw new ServiceError('invalid_limit', 'Max output tokens must be 128–' + Math.min(8192, entry.maxTokens));
|
|
54
|
+
if (workspace && (provider !== 'anthropic' || !/^[a-zA-Z0-9_-]{1,128}$/.test(workspace)))
|
|
29
55
|
throw new ServiceError('invalid_workspace', 'Invalid Anthropic workspace ID');
|
|
56
|
+
if (connection) {
|
|
57
|
+
if (Object.keys(connection).some((key) => !['endpoint', 'configuration'].includes(key)) ||
|
|
58
|
+
Object.keys(connection.configuration).some((key) => !ROUTING_FIELDS.includes(key)))
|
|
59
|
+
throw new ServiceError('invalid_provider', 'Unsupported provider configuration');
|
|
60
|
+
const url = new URL(connection.endpoint);
|
|
61
|
+
if (url.protocol !== 'https:' ||
|
|
62
|
+
url.username ||
|
|
63
|
+
url.password ||
|
|
64
|
+
url.hash ||
|
|
65
|
+
url.search ||
|
|
66
|
+
Object.values(connection.configuration).some((value) => typeof value !== 'string' || value.length > 4096 || /[^\x20-\x7e]/.test(value)))
|
|
67
|
+
throw new ServiceError('invalid_provider', 'Invalid provider connection');
|
|
68
|
+
}
|
|
30
69
|
return {
|
|
31
|
-
provider
|
|
70
|
+
provider,
|
|
32
71
|
model: id,
|
|
33
72
|
max_tokens: cap,
|
|
34
73
|
...(workspace ? { workspace_id: workspace } : {}),
|
|
74
|
+
...(connection ? { connection } : {}),
|
|
35
75
|
};
|
|
36
76
|
}
|
|
37
77
|
/** Validate the final serialized HTTP payload, after all Pi payload hooks. */
|
|
@@ -73,3 +113,11 @@ export function validateClaudePayload(value, route, frame, tools) {
|
|
|
73
113
|
if (payload[key] !== undefined)
|
|
74
114
|
throw new Error('Unsupported Claude execution feature');
|
|
75
115
|
}
|
|
116
|
+
export function providerHasOutputCap(route, auth) {
|
|
117
|
+
const model = getProviderModel(route);
|
|
118
|
+
return (model?.api !== 'openai-codex-responses' &&
|
|
119
|
+
!(model?.compat &&
|
|
120
|
+
'supportsMaxOutputTokens' in model.compat &&
|
|
121
|
+
model.compat.supportsMaxOutputTokens === false) &&
|
|
122
|
+
!(route.provider === 'openai' && !!auth.auth.apiKey && !auth.auth.apiKey.startsWith('sk-')));
|
|
123
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { clampMaxTokensToContext } from '@earendil-works/pi-ai/api/simple-options';
|
|
2
|
+
import { canonical } from './protocol.js';
|
|
3
|
+
import { getProviderModel, ROUTING_FIELDS } from './provider-config.js';
|
|
4
|
+
export function materialize(model, auth) {
|
|
5
|
+
let baseUrl = auth.auth.baseUrl ?? model.baseUrl;
|
|
6
|
+
for (const [key, value] of Object.entries(auth.env ?? {}))
|
|
7
|
+
baseUrl = baseUrl.replaceAll('{' + key + '}', value);
|
|
8
|
+
if (model.provider === 'azure') {
|
|
9
|
+
const env = auth.env ?? {};
|
|
10
|
+
baseUrl =
|
|
11
|
+
env.AZURE_OPENAI_BASE_URL ??
|
|
12
|
+
(env.AZURE_OPENAI_RESOURCE_NAME
|
|
13
|
+
? `https://${env.AZURE_OPENAI_RESOURCE_NAME}.openai.azure.com/openai/v1`
|
|
14
|
+
: baseUrl);
|
|
15
|
+
}
|
|
16
|
+
if (model.provider === 'google-vertex') {
|
|
17
|
+
const location = auth.env?.GOOGLE_CLOUD_LOCATION ?? 'global';
|
|
18
|
+
if (!/^[a-z0-9-]+$/.test(location))
|
|
19
|
+
throw new Error('Invalid Google Cloud location');
|
|
20
|
+
baseUrl =
|
|
21
|
+
location === 'global'
|
|
22
|
+
? 'https://aiplatform.googleapis.com'
|
|
23
|
+
: `https://${location}-aiplatform.googleapis.com`;
|
|
24
|
+
}
|
|
25
|
+
if (model.provider === 'amazon-bedrock') {
|
|
26
|
+
const region = /^arn:aws[^:]*:bedrock:([^:]+):/.exec(model.id)?.[1] ??
|
|
27
|
+
auth.env?.AWS_REGION ??
|
|
28
|
+
auth.env?.AWS_DEFAULT_REGION;
|
|
29
|
+
if (region) {
|
|
30
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)+$/.test(region))
|
|
31
|
+
throw new Error('Invalid AWS region');
|
|
32
|
+
baseUrl = `https://bedrock-runtime.${region}.amazonaws.com${region.startsWith('cn-') ? '.cn' : ''}`;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const url = new URL(baseUrl);
|
|
36
|
+
if (url.protocol !== 'https:' ||
|
|
37
|
+
url.username ||
|
|
38
|
+
url.password ||
|
|
39
|
+
url.hash ||
|
|
40
|
+
url.search ||
|
|
41
|
+
/[{}]/.test(baseUrl))
|
|
42
|
+
throw new Error('Provider endpoint is not configured');
|
|
43
|
+
return {
|
|
44
|
+
...model,
|
|
45
|
+
baseUrl: baseUrl.replace(/\/$/, ''),
|
|
46
|
+
headers: model.headers,
|
|
47
|
+
compat: {
|
|
48
|
+
...model.compat,
|
|
49
|
+
supportsMidConvoSystemMessages: false,
|
|
50
|
+
supportsMidConvoToolChanges: false,
|
|
51
|
+
supportsMidConvoEffort: false,
|
|
52
|
+
supportsAdditionalTools: false,
|
|
53
|
+
supportsToolSearch: false,
|
|
54
|
+
supportsOpenAIGrammarTools: false,
|
|
55
|
+
allowedFallbackModels: [],
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** Non-secret routing configuration becomes part of durable session identity. */
|
|
60
|
+
export function bindProviderConfiguration(route, auth) {
|
|
61
|
+
const catalog = getProviderModel(route);
|
|
62
|
+
if (!catalog)
|
|
63
|
+
throw new Error('Pinned provider model missing');
|
|
64
|
+
const model = materialize(catalog, auth);
|
|
65
|
+
const configuration = Object.fromEntries(ROUTING_FIELDS.flatMap((key) => (auth.env?.[key] ? [[key, auth.env[key]]] : [])));
|
|
66
|
+
const changed = route.provider !== 'anthropic' ||
|
|
67
|
+
model.baseUrl !== catalog.baseUrl ||
|
|
68
|
+
Object.keys(configuration).length > 0;
|
|
69
|
+
const binding = changed ? { endpoint: model.baseUrl, configuration } : undefined;
|
|
70
|
+
if (route.connection && canonical(route.connection) !== canonical(binding))
|
|
71
|
+
throw new Error('Provider endpoint or routing configuration changed; use the original configuration or a new session');
|
|
72
|
+
return binding ? { ...route, connection: binding } : route;
|
|
73
|
+
}
|
|
74
|
+
/** Leave room for the answer instead of emitting Pi's minimum 1,024-token thinking budget under a smaller ceiling. */
|
|
75
|
+
export function generationOptions(model, context, cap) {
|
|
76
|
+
const maxTokens = clampMaxTokensToContext(model, context, cap);
|
|
77
|
+
return { maxTokens, reasoning: maxTokens >= 2048 ? 'low' : undefined };
|
|
78
|
+
}
|