@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.
Files changed (110) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +25 -17
  3. package/dist/src/a2a-backend.js +225 -0
  4. package/dist/src/a2a-inbound.js +487 -0
  5. package/dist/src/a2a-peer.js +370 -0
  6. package/dist/src/a2a-protocol.js +250 -0
  7. package/dist/src/agent-profiles.js +289 -0
  8. package/dist/src/children.js +132 -26
  9. package/dist/src/claude-provider.js +2 -179
  10. package/dist/src/cli-help.js +107 -6
  11. package/dist/src/cli.js +477 -13
  12. package/dist/src/config.js +26 -3
  13. package/dist/src/credentials.js +7 -2
  14. package/dist/src/external-runtime.js +1 -0
  15. package/dist/src/external-work.js +583 -0
  16. package/dist/src/harness-backend.js +466 -0
  17. package/dist/src/harness-container.js +450 -0
  18. package/dist/src/harness-model-gateway.js +104 -0
  19. package/dist/src/harness-prepare.js +88 -0
  20. package/dist/src/harness-probe.js +111 -0
  21. package/dist/src/harness-protocol.js +325 -0
  22. package/dist/src/harness-readiness.js +50 -0
  23. package/dist/src/harness-rpc.js +206 -0
  24. package/dist/src/harness-tool-identity.js +87 -0
  25. package/dist/src/harness-wire-bridge.js +387 -0
  26. package/dist/src/host.js +6 -1
  27. package/dist/src/house-extension.js +147 -64
  28. package/dist/src/live-provider.js +301 -0
  29. package/dist/src/managed.js +33 -3
  30. package/dist/src/protocol.js +9 -5
  31. package/dist/src/provider-auth.js +300 -0
  32. package/dist/src/provider-config.js +65 -17
  33. package/dist/src/provider-model.js +78 -0
  34. package/dist/src/provider-payload.js +264 -0
  35. package/dist/src/provider-transport.js +36 -0
  36. package/dist/src/provider-usage.js +59 -0
  37. package/dist/src/repository-snapshots.js +219 -0
  38. package/dist/src/service.js +274 -9
  39. package/dist/src/store.js +44 -2
  40. package/dist/src/supervisor.js +246 -35
  41. package/dist/src/task-budget.js +17 -8
  42. package/dist/src/task-context.js +15 -0
  43. package/dist/src/task-driver.js +38 -14
  44. package/dist/src/task-policy.js +20 -1
  45. package/dist/src/task-verification.js +3 -0
  46. package/dist/src/task-work.js +31 -0
  47. package/dist/src/team-work.js +378 -0
  48. package/dist/src/tui-view.js +65 -10
  49. package/dist/src/tui.js +4 -2
  50. package/dist/src/usage.js +18 -0
  51. package/dist/src/worker-boundary.js +298 -0
  52. package/dist/src/worker-workspaces.js +274 -0
  53. package/dist/src/workspaces.js +6 -2
  54. package/harness-runtime/codex-pins.json +10 -0
  55. package/harness-runtime/package-lock.json +560 -0
  56. package/harness-runtime/package.json +9 -0
  57. package/npm-shrinkwrap.json +57 -2
  58. package/package.json +2 -1
  59. package/src/a2a-backend.ts +254 -0
  60. package/src/a2a-inbound.ts +585 -0
  61. package/src/a2a-peer.ts +441 -0
  62. package/src/a2a-protocol.ts +286 -0
  63. package/src/agent-profiles.ts +379 -0
  64. package/src/children.ts +168 -27
  65. package/src/claude-provider.ts +2 -189
  66. package/src/cli-help.ts +107 -6
  67. package/src/cli.ts +602 -12
  68. package/src/config.ts +34 -3
  69. package/src/credentials.ts +9 -2
  70. package/src/external-runtime.ts +54 -0
  71. package/src/external-work.ts +663 -0
  72. package/src/harness-backend.ts +557 -0
  73. package/src/harness-container.ts +543 -0
  74. package/src/harness-model-gateway.ts +138 -0
  75. package/src/harness-prepare.ts +113 -0
  76. package/src/harness-probe.ts +150 -0
  77. package/src/harness-protocol.ts +388 -0
  78. package/src/harness-readiness.ts +71 -0
  79. package/src/harness-rpc.ts +244 -0
  80. package/src/harness-tool-identity.ts +91 -0
  81. package/src/harness-wire-bridge.ts +418 -0
  82. package/src/host.ts +11 -6
  83. package/src/house-extension.ts +165 -66
  84. package/src/live-provider.ts +347 -0
  85. package/src/managed.ts +43 -4
  86. package/src/protocol.ts +36 -8
  87. package/src/provider-auth.ts +336 -0
  88. package/src/provider-config.ts +89 -18
  89. package/src/provider-model.ts +86 -0
  90. package/src/provider-payload.ts +263 -0
  91. package/src/provider-transport.ts +51 -0
  92. package/src/provider-usage.ts +64 -0
  93. package/src/repository-snapshots.ts +259 -0
  94. package/src/service.ts +446 -11
  95. package/src/store.ts +60 -4
  96. package/src/supervisor.ts +294 -41
  97. package/src/task-budget.ts +24 -11
  98. package/src/task-context.ts +17 -0
  99. package/src/task-driver.ts +51 -15
  100. package/src/task-policy.ts +23 -3
  101. package/src/task-verification.ts +2 -0
  102. package/src/task-work.ts +46 -1
  103. package/src/team-work.ts +554 -0
  104. package/src/tui-client.ts +5 -0
  105. package/src/tui-view.ts +164 -12
  106. package/src/tui.ts +5 -2
  107. package/src/usage.ts +17 -0
  108. package/src/worker-boundary.ts +395 -0
  109. package/src/worker-workspaces.ts +390 -0
  110. package/src/workspaces.ts +4 -2
@@ -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 === 'review' ? 'read-only' : await connector.version(target, AbortSignal.timeout(2500));
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 === 'review' ? 'local-review-v1' : connector.id,
156
- replay: kind === 'review' ? 'read-only' : connector.replay,
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);
@@ -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 { anthropicProvider } from '@earendil-works/pi-ai/providers/anthropic';
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 anthropicProvider()
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 Claude options');
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
- if (provider !== 'anthropic')
21
- throw new ServiceError('invalid_provider', 'Provider must be offline or anthropic');
22
- const id = model ?? DEFAULT_CLAUDE_MODEL;
23
- if (!['claude-sonnet-5-5', 'claude-sonnet-4-6'].includes(id) || !getClaudeModel(id))
24
- throw new ServiceError('invalid_model', 'This route supports pinned claude-sonnet-5-5 or claude-sonnet-4-6');
25
- const cap = maxTokens ?? 4096;
26
- if (!Number.isSafeInteger(cap) || cap < 128 || cap > 8192)
27
- throw new ServiceError('invalid_limit', 'Max output tokens must be 128–8192');
28
- if (workspace && !/^[a-zA-Z0-9_-]{1,128}$/.test(workspace))
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: 'anthropic',
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
+ }