@amalgm/chat 0.1.2 → 0.2.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/AGENTS.md +98 -0
- package/PURPOSE.md +143 -112
- package/README.md +82 -54
- package/dist/acp/index.d.ts +7 -0
- package/dist/acp/index.d.ts.map +1 -0
- package/dist/acp/index.js +7 -0
- package/dist/acp/index.js.map +1 -0
- package/dist/acp/projection.d.ts +30 -0
- package/dist/acp/projection.d.ts.map +1 -0
- package/dist/acp/projection.js +147 -0
- package/dist/acp/projection.js.map +1 -0
- package/dist/api/conversations.d.ts +66 -0
- package/dist/api/conversations.d.ts.map +1 -0
- package/dist/api/conversations.js +202 -0
- package/dist/api/conversations.js.map +1 -0
- package/dist/api/index.d.ts +3 -3
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +1 -2
- package/dist/api/index.js.map +1 -1
- package/dist/conversations/index.d.ts +3 -0
- package/dist/conversations/index.d.ts.map +1 -0
- package/dist/conversations/index.js +3 -0
- package/dist/conversations/index.js.map +1 -0
- package/dist/conversations/transcript.d.ts +13 -0
- package/dist/conversations/transcript.d.ts.map +1 -0
- package/dist/conversations/transcript.js +171 -0
- package/dist/conversations/transcript.js.map +1 -0
- package/dist/conversations/types.d.ts +147 -0
- package/dist/conversations/types.d.ts.map +1 -0
- package/dist/conversations/types.js +9 -0
- package/dist/conversations/types.js.map +1 -0
- package/dist/events/constructors.d.ts +9 -15
- package/dist/events/constructors.d.ts.map +1 -1
- package/dist/events/constructors.js +6 -12
- package/dist/events/constructors.js.map +1 -1
- package/dist/events/index.d.ts +2 -3
- package/dist/events/index.d.ts.map +1 -1
- package/dist/events/index.js +2 -3
- package/dist/events/index.js.map +1 -1
- package/dist/events/schema.d.ts +2 -5
- package/dist/events/schema.d.ts.map +1 -1
- package/dist/events/schema.js +2 -5
- package/dist/events/schema.js.map +1 -1
- package/dist/execution/contract.d.ts +51 -0
- package/dist/execution/contract.d.ts.map +1 -0
- package/dist/execution/contract.js +127 -0
- package/dist/execution/contract.js.map +1 -0
- package/dist/execution/index.d.ts +6 -0
- package/dist/execution/index.d.ts.map +1 -0
- package/dist/execution/index.js +4 -0
- package/dist/execution/index.js.map +1 -0
- package/dist/execution/revision.d.ts +5 -0
- package/dist/execution/revision.d.ts.map +1 -0
- package/dist/execution/revision.js +17 -0
- package/dist/execution/revision.js.map +1 -0
- package/dist/execution/store.d.ts +26 -0
- package/dist/execution/store.d.ts.map +1 -0
- package/dist/execution/store.js +38 -0
- package/dist/execution/store.js.map +1 -0
- package/dist/index.d.ts +8 -25
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -25
- package/dist/index.js.map +1 -1
- package/dist/input/index.d.ts +3 -0
- package/dist/input/index.d.ts.map +1 -0
- package/dist/input/index.js +2 -0
- package/dist/input/index.js.map +1 -0
- package/dist/input/prompt.d.ts +17 -0
- package/dist/input/prompt.d.ts.map +1 -0
- package/dist/input/prompt.js +104 -0
- package/dist/input/prompt.js.map +1 -0
- package/dist/normalizers/claude.d.ts +6 -9
- package/dist/normalizers/claude.d.ts.map +1 -1
- package/dist/normalizers/claude.js +5 -8
- package/dist/normalizers/claude.js.map +1 -1
- package/dist/normalizers/codex.d.ts +5 -8
- package/dist/normalizers/codex.d.ts.map +1 -1
- package/dist/normalizers/codex.js +4 -7
- package/dist/normalizers/codex.js.map +1 -1
- package/dist/normalizers/cursor.d.ts +1 -2
- package/dist/normalizers/cursor.d.ts.map +1 -1
- package/dist/normalizers/cursor.js +2 -3
- package/dist/normalizers/cursor.js.map +1 -1
- package/dist/normalizers/index.d.ts +3 -5
- package/dist/normalizers/index.d.ts.map +1 -1
- package/dist/normalizers/index.js +3 -5
- package/dist/normalizers/index.js.map +1 -1
- package/dist/normalizers/opencode.d.ts +2 -4
- package/dist/normalizers/opencode.d.ts.map +1 -1
- package/dist/normalizers/opencode.js +2 -4
- package/dist/normalizers/opencode.js.map +1 -1
- package/dist/normalizers/pi.d.ts +3 -4
- package/dist/normalizers/pi.d.ts.map +1 -1
- package/dist/normalizers/pi.js +3 -4
- package/dist/normalizers/pi.js.map +1 -1
- package/dist/sessions/chat.d.ts +176 -0
- package/dist/sessions/chat.d.ts.map +1 -0
- package/dist/sessions/chat.js +701 -0
- package/dist/sessions/chat.js.map +1 -0
- package/dist/sessions/index.d.ts +3 -0
- package/dist/sessions/index.d.ts.map +1 -0
- package/dist/sessions/index.js +2 -0
- package/dist/sessions/index.js.map +1 -0
- package/dist/transcript/index.d.ts +1 -1
- package/dist/transcript/index.js +1 -1
- package/dist/transcript/message-reducer.d.ts +1 -1
- package/dist/transcript/message-reducer.js +1 -1
- package/dist/transcript/part-accumulator.d.ts +1 -1
- package/dist/transcript/part-accumulator.js +1 -1
- package/dist/transcript/tool-display.d.ts +1 -1
- package/dist/transcript/tool-display.js +1 -1
- package/dist/transcript/tool-shape.d.ts +1 -1
- package/dist/transcript/tool-shape.js +1 -1
- package/dist/usage/finalize.d.ts +7 -9
- package/dist/usage/finalize.d.ts.map +1 -1
- package/dist/usage/finalize.js +11 -13
- package/dist/usage/finalize.js.map +1 -1
- package/dist/usage/index.d.ts +2 -3
- package/dist/usage/index.d.ts.map +1 -1
- package/dist/usage/index.js +2 -3
- package/dist/usage/index.js.map +1 -1
- package/dist/usage/model-catalog.d.ts +2 -3
- package/dist/usage/model-catalog.d.ts.map +1 -1
- package/dist/usage/model-catalog.js +10 -3
- package/dist/usage/model-catalog.js.map +1 -1
- package/dist/usage/normalize.d.ts +5 -7
- package/dist/usage/normalize.d.ts.map +1 -1
- package/dist/usage/normalize.js +3 -5
- package/dist/usage/normalize.js.map +1 -1
- package/dist/usage/ports.d.ts +14 -24
- package/dist/usage/ports.d.ts.map +1 -1
- package/dist/usage/ports.js +9 -17
- package/dist/usage/ports.js.map +1 -1
- package/dist/usage/steps.d.ts +70 -0
- package/dist/usage/steps.d.ts.map +1 -0
- package/dist/usage/steps.js +152 -0
- package/dist/usage/steps.js.map +1 -0
- package/dist/usage/tokens.d.ts +4 -7
- package/dist/usage/tokens.d.ts.map +1 -1
- package/dist/usage/tokens.js +4 -7
- package/dist/usage/tokens.js.map +1 -1
- package/docs/contracts/acp-and-step-usage.md +51 -0
- package/docs/contracts/conversation-persistence.md +47 -0
- package/docs/contracts/input-and-execution.md +177 -0
- package/docs/contracts/platform-authorization.md +17 -0
- package/host/adapters/acp-capabilities.js +30 -0
- package/host/adapters/acp-client.js +156 -0
- package/host/adapters/acp.js +284 -0
- package/host/adapters/claude.js +235 -0
- package/host/adapters/codex.js +733 -0
- package/host/adapters/cursor.js +347 -0
- package/host/adapters/input-capabilities.js +89 -0
- package/host/adapters/opencode.js +486 -0
- package/host/adapters/pi.js +519 -0
- package/host/adapters/prompt.js +212 -0
- package/host/auth.js +257 -0
- package/host/credentials/store.js +209 -0
- package/host/http.d.ts +32 -0
- package/host/http.js +238 -0
- package/host/index.d.ts +84 -0
- package/host/index.js +59 -0
- package/host/lib/runtime-paths.js +44 -0
- package/host/model-resolution.js +313 -0
- package/host/native-contract.js +185 -0
- package/host/native-runtime.js +194 -0
- package/host/permissions.js +27 -0
- package/host/platform-egress.js +114 -0
- package/host/recorder.js +61 -0
- package/host/sqlite/index.d.ts +69 -0
- package/host/sqlite/index.js +704 -0
- package/host/tooling/mcp-bundle.js +214 -0
- package/host/tooling/mcp-relay.js +83 -0
- package/host/tooling/native-binaries.js +667 -0
- package/host/tooling/native-config.js +460 -0
- package/host/tooling/package-import.js +111 -0
- package/host/tooling/runtime-home.js +26 -0
- package/host/tooling/system-instructions.js +69 -0
- package/host/tooling/system-prompt.js +35 -0
- package/package.json +44 -24
- package/skills/chat/SKILL.md +97 -0
- package/skills/chat/agents/openai.yaml +4 -0
- package/skills/chat/references/contracts.md +131 -0
- package/dist/api/chat-payloads.d.ts +0 -10
- package/dist/api/chat-payloads.d.ts.map +0 -1
- package/dist/api/chat-payloads.js +0 -57
- package/dist/api/chat-payloads.js.map +0 -1
- package/dist/contract/chat-input.d.ts +0 -144
- package/dist/contract/chat-input.d.ts.map +0 -1
- package/dist/contract/chat-input.js +0 -261
- package/dist/contract/chat-input.js.map +0 -1
- package/dist/contract/create.d.ts +0 -191
- package/dist/contract/create.d.ts.map +0 -1
- package/dist/contract/create.js +0 -249
- package/dist/contract/create.js.map +0 -1
- package/dist/contract/freeze.d.ts +0 -85
- package/dist/contract/freeze.d.ts.map +0 -1
- package/dist/contract/freeze.js +0 -151
- package/dist/contract/freeze.js.map +0 -1
- package/dist/contract/harnesses.d.ts +0 -21
- package/dist/contract/harnesses.d.ts.map +0 -1
- package/dist/contract/harnesses.js +0 -39
- package/dist/contract/harnesses.js.map +0 -1
- package/dist/contract/index.d.ts +0 -17
- package/dist/contract/index.d.ts.map +0 -1
- package/dist/contract/index.js +0 -17
- package/dist/contract/index.js.map +0 -1
- package/dist/contract/input.d.ts +0 -45
- package/dist/contract/input.d.ts.map +0 -1
- package/dist/contract/input.js +0 -78
- package/dist/contract/input.js.map +0 -1
- package/dist/contract/models.d.ts +0 -57
- package/dist/contract/models.d.ts.map +0 -1
- package/dist/contract/models.js +0 -380
- package/dist/contract/models.js.map +0 -1
- package/dist/contract/payload-merge.d.ts +0 -76
- package/dist/contract/payload-merge.d.ts.map +0 -1
- package/dist/contract/payload-merge.js +0 -215
- package/dist/contract/payload-merge.js.map +0 -1
- package/dist/events/tool-display.d.ts +0 -22
- package/dist/events/tool-display.d.ts.map +0 -1
- package/dist/events/tool-display.js +0 -60
- package/dist/events/tool-display.js.map +0 -1
- package/dist/events/tool-shape.d.ts +0 -48
- package/dist/events/tool-shape.d.ts.map +0 -1
- package/dist/events/tool-shape.js +0 -225
- package/dist/events/tool-shape.js.map +0 -1
- package/dist/relay/index.d.ts +0 -65
- package/dist/relay/index.d.ts.map +0 -1
- package/dist/relay/index.js +0 -197
- package/dist/relay/index.js.map +0 -1
- package/dist/turns/index.d.ts +0 -11
- package/dist/turns/index.d.ts.map +0 -1
- package/dist/turns/index.js +0 -11
- package/dist/turns/index.js.map +0 -1
- package/dist/turns/queries.d.ts +0 -36
- package/dist/turns/queries.d.ts.map +0 -1
- package/dist/turns/queries.js +0 -37
- package/dist/turns/queries.js.map +0 -1
- package/dist/turns/replay.d.ts +0 -105
- package/dist/turns/replay.d.ts.map +0 -1
- package/dist/turns/replay.js +0 -163
- package/dist/turns/replay.js.map +0 -1
- package/dist/turns/status.d.ts +0 -49
- package/dist/turns/status.d.ts.map +0 -1
- package/dist/turns/status.js +0 -56
- package/dist/turns/status.js.map +0 -1
- package/dist/turns/turn-store.d.ts +0 -109
- package/dist/turns/turn-store.d.ts.map +0 -1
- package/dist/turns/turn-store.js +0 -169
- package/dist/turns/turn-store.js.map +0 -1
- package/dist/warm/client-cache.d.ts +0 -47
- package/dist/warm/client-cache.d.ts.map +0 -1
- package/dist/warm/client-cache.js +0 -48
- package/dist/warm/client-cache.js.map +0 -1
- package/dist/warm/envelope-cache.d.ts +0 -79
- package/dist/warm/envelope-cache.d.ts.map +0 -1
- package/dist/warm/envelope-cache.js +0 -85
- package/dist/warm/envelope-cache.js.map +0 -1
- package/dist/warm/index.d.ts +0 -18
- package/dist/warm/index.d.ts.map +0 -1
- package/dist/warm/index.js +0 -12
- package/dist/warm/index.js.map +0 -1
- package/dist/warm/payload.d.ts +0 -137
- package/dist/warm/payload.d.ts.map +0 -1
- package/dist/warm/payload.js +0 -112
- package/dist/warm/payload.js.map +0 -1
- package/dist/warm/revision.d.ts +0 -70
- package/dist/warm/revision.d.ts.map +0 -1
- package/dist/warm/revision.js +0 -63
- package/dist/warm/revision.js.map +0 -1
- package/dist/warm/store-policy.d.ts +0 -57
- package/dist/warm/store-policy.d.ts.map +0 -1
- package/dist/warm/store-policy.js +0 -89
- package/dist/warm/store-policy.js.map +0 -1
- package/dist/warm/store.d.ts +0 -42
- package/dist/warm/store.d.ts.map +0 -1
- package/dist/warm/store.js +0 -69
- package/dist/warm/store.js.map +0 -1
- package/dist/wire/frames.d.ts +0 -208
- package/dist/wire/frames.d.ts.map +0 -1
- package/dist/wire/frames.js +0 -245
- package/dist/wire/frames.js.map +0 -1
- package/dist/wire/index.d.ts +0 -7
- package/dist/wire/index.d.ts.map +0 -1
- package/dist/wire/index.js +0 -7
- package/dist/wire/index.js.map +0 -1
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
export class UnsupportedPromptPartError extends Error {
|
|
5
|
+
constructor(adapterId, index, part, reason) {
|
|
6
|
+
super(`${adapterId} cannot accept prompt.parts[${index}] (${part.type}): ${reason}`);
|
|
7
|
+
this.name = 'UnsupportedPromptPartError';
|
|
8
|
+
this.code = 'UNSUPPORTED_PROMPT_PART';
|
|
9
|
+
this.adapterId = adapterId;
|
|
10
|
+
this.partIndex = index;
|
|
11
|
+
this.partType = part.type;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function dataUrl(mimeType, data) {
|
|
16
|
+
return `data:${mimeType};base64,${data}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function resourceName(part) {
|
|
20
|
+
if (part.name) return part.name;
|
|
21
|
+
const uri = part.resource?.uri || part.uri || '';
|
|
22
|
+
try {
|
|
23
|
+
const parsed = new URL(uri);
|
|
24
|
+
return path.basename(decodeURIComponent(parsed.pathname)) || parsed.hostname || uri;
|
|
25
|
+
} catch {
|
|
26
|
+
return uri || 'attachment';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function linkedText(part) {
|
|
31
|
+
return `[Attachment: ${resourceName(part)}](${part.uri})`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function embeddedText(part) {
|
|
35
|
+
const resource = part.resource;
|
|
36
|
+
return `<attachment uri=${JSON.stringify(resource.uri)}\n>${resource.text}</attachment>`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function localPath(uri) {
|
|
40
|
+
if (!String(uri).startsWith('file:')) return null;
|
|
41
|
+
try {
|
|
42
|
+
return fileURLToPath(uri);
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function mimeOf(part) {
|
|
49
|
+
return part.mimeType || part.resource?.mimeType || 'application/octet-stream';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function textProjection(part) {
|
|
53
|
+
if (part.type === 'text') return part.text;
|
|
54
|
+
if (part.type === 'resource_link') return linkedText(part);
|
|
55
|
+
if (part.type === 'resource' && typeof part.resource.text === 'string') return embeddedText(part);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function acpPromptParts(prompt, preamble = '') {
|
|
60
|
+
const parts = prompt.parts.map((part) => structuredClone(part));
|
|
61
|
+
return preamble ? [{ type: 'text', text: preamble }, ...parts] : parts;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function codexPromptInput(prompt) {
|
|
65
|
+
return prompt.parts.flatMap((part, index) => {
|
|
66
|
+
if (part.type === 'text') return [{ type: 'text', text: part.text }];
|
|
67
|
+
if (part.type === 'image') {
|
|
68
|
+
return [{ type: 'image', url: dataUrl(part.mimeType, part.data) }];
|
|
69
|
+
}
|
|
70
|
+
if (part.type === 'audio') {
|
|
71
|
+
return [{ type: 'audio', url: dataUrl(part.mimeType, part.data) }];
|
|
72
|
+
}
|
|
73
|
+
if (part.type === 'resource_link') {
|
|
74
|
+
const file = localPath(part.uri);
|
|
75
|
+
const mime = mimeOf(part);
|
|
76
|
+
if (!file && mime.startsWith('image/')) return [{ type: 'image', url: part.uri }];
|
|
77
|
+
if (!file && mime.startsWith('audio/')) return [{ type: 'audio', url: part.uri }];
|
|
78
|
+
if (!file) return [{ type: 'text', text: linkedText(part) }];
|
|
79
|
+
if (mime.startsWith('image/')) return [{ type: 'localImage', path: file }];
|
|
80
|
+
if (mime.startsWith('audio/')) return [{ type: 'localAudio', path: file }];
|
|
81
|
+
return [{ type: 'mention', name: resourceName(part), path: file }];
|
|
82
|
+
}
|
|
83
|
+
if (part.type === 'resource') {
|
|
84
|
+
const mime = mimeOf(part);
|
|
85
|
+
if (mime.startsWith('image/')) {
|
|
86
|
+
return [{ type: 'image', url: dataUrl(mime, part.resource.blob) }];
|
|
87
|
+
}
|
|
88
|
+
if (mime.startsWith('audio/')) {
|
|
89
|
+
return [{ type: 'audio', url: dataUrl(mime, part.resource.blob) }];
|
|
90
|
+
}
|
|
91
|
+
throw new UnsupportedPromptPartError('codex', index, part, `embedded ${mime} is not a Codex input type`);
|
|
92
|
+
}
|
|
93
|
+
throw new UnsupportedPromptPartError('codex', index, part, 'unknown content type');
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function claudeContent(prompt) {
|
|
98
|
+
return prompt.parts.map((part, index) => {
|
|
99
|
+
if (part.type === 'text') return { type: 'text', text: part.text };
|
|
100
|
+
if (part.type === 'image') {
|
|
101
|
+
if (!['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(part.mimeType)) {
|
|
102
|
+
throw new UnsupportedPromptPartError('claude_code', index, part, `image type ${part.mimeType} is not supported`);
|
|
103
|
+
}
|
|
104
|
+
return { type: 'image', source: { type: 'base64', media_type: part.mimeType, data: part.data } };
|
|
105
|
+
}
|
|
106
|
+
if (part.type === 'audio') {
|
|
107
|
+
throw new UnsupportedPromptPartError('claude_code', index, part, 'Claude Code has no audio input block');
|
|
108
|
+
}
|
|
109
|
+
if (part.type === 'resource_link') {
|
|
110
|
+
const mime = mimeOf(part);
|
|
111
|
+
if (!localPath(part.uri) && ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(mime)) {
|
|
112
|
+
return { type: 'image', source: { type: 'url', url: part.uri } };
|
|
113
|
+
}
|
|
114
|
+
if (!localPath(part.uri) && mime === 'application/pdf') {
|
|
115
|
+
return { type: 'document', source: { type: 'url', url: part.uri }, title: resourceName(part) };
|
|
116
|
+
}
|
|
117
|
+
return { type: 'text', text: linkedText(part) };
|
|
118
|
+
}
|
|
119
|
+
if (part.type === 'resource') {
|
|
120
|
+
if (typeof part.resource.text === 'string') {
|
|
121
|
+
return {
|
|
122
|
+
type: 'document',
|
|
123
|
+
source: { type: 'text', media_type: 'text/plain', data: part.resource.text },
|
|
124
|
+
title: resourceName(part),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const mime = mimeOf(part);
|
|
128
|
+
if (mime === 'application/pdf') {
|
|
129
|
+
return { type: 'document', source: { type: 'base64', media_type: mime, data: part.resource.blob } };
|
|
130
|
+
}
|
|
131
|
+
if (mime.startsWith('image/')) {
|
|
132
|
+
if (!['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(mime)) {
|
|
133
|
+
throw new UnsupportedPromptPartError('claude_code', index, part, `image type ${mime} is not supported`);
|
|
134
|
+
}
|
|
135
|
+
return { type: 'image', source: { type: 'base64', media_type: mime, data: part.resource.blob } };
|
|
136
|
+
}
|
|
137
|
+
throw new UnsupportedPromptPartError('claude_code', index, part, `embedded ${mime} is not a Claude input block`);
|
|
138
|
+
}
|
|
139
|
+
throw new UnsupportedPromptPartError('claude_code', index, part, 'unknown content type');
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function claudePrompt(prompt) {
|
|
144
|
+
if (prompt.parts.every((part) => part.type === 'text')) {
|
|
145
|
+
return prompt.parts.map((part) => part.text).join('\n\n');
|
|
146
|
+
}
|
|
147
|
+
const content = claudeContent(prompt);
|
|
148
|
+
return (async function* messages() {
|
|
149
|
+
yield {
|
|
150
|
+
type: 'user',
|
|
151
|
+
message: { role: 'user', content },
|
|
152
|
+
parent_tool_use_id: null,
|
|
153
|
+
};
|
|
154
|
+
}());
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function openCodePromptParts(prompt) {
|
|
158
|
+
return prompt.parts.map((part) => {
|
|
159
|
+
if (part.type === 'text') return { type: 'text', text: part.text };
|
|
160
|
+
if (part.type === 'resource_link') {
|
|
161
|
+
return {
|
|
162
|
+
type: 'file',
|
|
163
|
+
mime: mimeOf(part),
|
|
164
|
+
filename: resourceName(part),
|
|
165
|
+
url: part.uri,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
if (part.type === 'image' || part.type === 'audio') {
|
|
169
|
+
return {
|
|
170
|
+
type: 'file',
|
|
171
|
+
mime: part.mimeType,
|
|
172
|
+
url: dataUrl(part.mimeType, part.data),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const resource = part.resource;
|
|
176
|
+
const mime = mimeOf(part);
|
|
177
|
+
const data = typeof resource.text === 'string'
|
|
178
|
+
? Buffer.from(resource.text, 'utf8').toString('base64')
|
|
179
|
+
: resource.blob;
|
|
180
|
+
return {
|
|
181
|
+
type: 'file',
|
|
182
|
+
mime,
|
|
183
|
+
filename: resourceName(part),
|
|
184
|
+
url: dataUrl(mime, data),
|
|
185
|
+
};
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function piPrompt(prompt) {
|
|
190
|
+
const messages = [];
|
|
191
|
+
const images = [];
|
|
192
|
+
for (const [index, part] of prompt.parts.entries()) {
|
|
193
|
+
const text = textProjection(part);
|
|
194
|
+
if (text !== null) {
|
|
195
|
+
messages.push(text);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (part.type === 'image') {
|
|
199
|
+
images.push({ type: 'image', data: part.data, mimeType: part.mimeType });
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (part.type === 'resource') {
|
|
203
|
+
const mime = mimeOf(part);
|
|
204
|
+
if (mime.startsWith('image/')) {
|
|
205
|
+
images.push({ type: 'image', data: part.resource.blob, mimeType: mime });
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
throw new UnsupportedPromptPartError('pi', index, part, 'Pi RPC accepts text and images');
|
|
210
|
+
}
|
|
211
|
+
return { message: messages.join('\n\n'), images };
|
|
212
|
+
}
|
package/host/auth.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/** Native credential resolution and the scrubbed process environment. */
|
|
2
|
+
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { resolveCredential } from './credentials/store.js';
|
|
8
|
+
|
|
9
|
+
const AUTH_METHODS = Object.freeze({
|
|
10
|
+
claude_code: Object.freeze(['amalgm', 'byok', 'provider_auth']),
|
|
11
|
+
codex: Object.freeze(['amalgm', 'byok', 'provider_auth']),
|
|
12
|
+
cursor: Object.freeze(['provider_auth', 'byok']),
|
|
13
|
+
opencode: Object.freeze(['amalgm', 'byok', 'provider_auth']),
|
|
14
|
+
pi: Object.freeze(['amalgm', 'byok', 'provider_auth']),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function shellValue(raw) {
|
|
18
|
+
const value = String(raw ?? '').trim();
|
|
19
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
20
|
+
return value.slice(1, -1);
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readByokFile(amalgmDir) {
|
|
26
|
+
const out = {};
|
|
27
|
+
try {
|
|
28
|
+
const raw = fs.readFileSync(path.join(amalgmDir, '.amalgm-credentials'), 'utf8');
|
|
29
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
30
|
+
const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
31
|
+
if (match) out[match[1]] = shellValue(match[2]);
|
|
32
|
+
}
|
|
33
|
+
} catch {}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function byokKeyFor(harness, values) {
|
|
38
|
+
if (harness === 'claude_code') return values.ANTHROPIC_API_KEY || '';
|
|
39
|
+
if (harness === 'codex') return values.OPENAI_API_KEY || values.CODEX_API_KEY || '';
|
|
40
|
+
if (harness === 'cursor') return values.CURSOR_API_KEY || '';
|
|
41
|
+
if (harness === 'pi') return values.AI_GATEWAY_API_KEY || '';
|
|
42
|
+
return '';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function providerKindForModel(modelId) {
|
|
46
|
+
const provider = String(modelId || '').split('/')[0];
|
|
47
|
+
if (provider === 'anthropic') return 'anthropic_compatible';
|
|
48
|
+
if (provider === 'openai') return 'openai_compatible';
|
|
49
|
+
return provider ? 'ai_gateway' : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function resolveByokCredential(amalgmDir, { credentialId, harness, modelId }) {
|
|
53
|
+
if (credentialId) {
|
|
54
|
+
const credential = resolveCredential(amalgmDir, { credentialId, harness });
|
|
55
|
+
if (!credential) throw new Error(`BYOK credential not found: ${credentialId}`);
|
|
56
|
+
return credential;
|
|
57
|
+
}
|
|
58
|
+
const preferred = harness === 'cursor' ? 'cursor_api' : providerKindForModel(modelId);
|
|
59
|
+
if (preferred) {
|
|
60
|
+
const credential = resolveCredential(amalgmDir, { harness, providerKind: preferred });
|
|
61
|
+
if (credential) return credential;
|
|
62
|
+
if (preferred === 'ai_gateway') return null;
|
|
63
|
+
}
|
|
64
|
+
if ((harness === 'opencode' || harness === 'pi') && preferred !== 'ai_gateway') {
|
|
65
|
+
const gatewayCredential = resolveCredential(amalgmDir, { harness, providerKind: 'ai_gateway' });
|
|
66
|
+
if (gatewayCredential) return gatewayCredential;
|
|
67
|
+
}
|
|
68
|
+
return resolveCredential(amalgmDir, { harness });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function fingerprint(value) {
|
|
72
|
+
return value ? crypto.createHash('sha256').update(String(value)).digest('hex').slice(0, 16) : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function safePathSegment(value) {
|
|
76
|
+
const clean = String(value || '').trim().replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
77
|
+
return clean || 'default';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function findStableJsonIdentity(value, depth = 0) {
|
|
81
|
+
if (!value || typeof value !== 'object' || depth > 4) return null;
|
|
82
|
+
const keys = ['account_id', 'accountId', 'user_id', 'userId', 'email', 'login', 'username', 'sub'];
|
|
83
|
+
for (const key of keys) {
|
|
84
|
+
const candidate = value[key];
|
|
85
|
+
if (typeof candidate === 'string' && candidate.trim()) return `${key}:${candidate.trim()}`;
|
|
86
|
+
}
|
|
87
|
+
for (const child of Object.values(value)) {
|
|
88
|
+
const found = findStableJsonIdentity(child, depth + 1);
|
|
89
|
+
if (found) return found;
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function readJsonIdentity(file) {
|
|
95
|
+
try {
|
|
96
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
97
|
+
return findStableJsonIdentity(JSON.parse(raw));
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function providerAuthIdentity(harness) {
|
|
104
|
+
if (harness === 'codex') {
|
|
105
|
+
return readJsonIdentity(path.join(os.homedir(), '.codex', 'auth.json')) || 'codex:provider-default';
|
|
106
|
+
}
|
|
107
|
+
if (harness === 'claude_code') {
|
|
108
|
+
return readJsonIdentity(path.join(os.homedir(), '.claude.json'))
|
|
109
|
+
|| readJsonIdentity(path.join(os.homedir(), '.claude', 'auth.json'))
|
|
110
|
+
|| 'claude_code:provider-default';
|
|
111
|
+
}
|
|
112
|
+
return `${harness || 'provider'}:provider-default`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function runtimeHome({ runtimeHomeRoot, harness, agentConfigId }) {
|
|
116
|
+
const harnessHome = path.join(runtimeHomeRoot, safePathSegment(harness));
|
|
117
|
+
return path.join(
|
|
118
|
+
harnessHome,
|
|
119
|
+
'agents',
|
|
120
|
+
safePathSegment(agentConfigId || harness),
|
|
121
|
+
'home',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function providerAuthFingerprint(harness) {
|
|
126
|
+
return fingerprint(providerAuthIdentity(harness));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function authEnvelope({
|
|
130
|
+
harness,
|
|
131
|
+
authMethod,
|
|
132
|
+
sessionId,
|
|
133
|
+
localBaseUrl,
|
|
134
|
+
proxyBaseUrl,
|
|
135
|
+
amalgmDir,
|
|
136
|
+
runtimeHomeRoot = path.join(amalgmDir, 'cli-homes'),
|
|
137
|
+
credentialId,
|
|
138
|
+
modelId,
|
|
139
|
+
agentConfigId,
|
|
140
|
+
}) {
|
|
141
|
+
const supported = AUTH_METHODS[harness];
|
|
142
|
+
if (supported && !supported.includes(authMethod)) {
|
|
143
|
+
throw new Error(`${harness} does not support ${authMethod} auth`);
|
|
144
|
+
}
|
|
145
|
+
const method = authMethod;
|
|
146
|
+
const byok = readByokFile(amalgmDir);
|
|
147
|
+
let baseUrl = null;
|
|
148
|
+
let forwardBaseUrl = null;
|
|
149
|
+
let tokenRef = null;
|
|
150
|
+
let tokenFingerprint = null;
|
|
151
|
+
let credential = null;
|
|
152
|
+
if (method === 'amalgm') {
|
|
153
|
+
if (!localBaseUrl || !proxyBaseUrl) {
|
|
154
|
+
throw new Error('Platform auth requires localBaseUrl and proxyBaseUrl');
|
|
155
|
+
}
|
|
156
|
+
const providerPath = harness === 'claude_code'
|
|
157
|
+
? '/anthropic'
|
|
158
|
+
: harness === 'opencode'
|
|
159
|
+
? '/ai_gateway/v3/ai'
|
|
160
|
+
// Pi's provider client appends its own API paths (/v1/messages or
|
|
161
|
+
// /v1/chat/completions), which the proxy serves under /ai_gateway.
|
|
162
|
+
: harness === 'pi'
|
|
163
|
+
? '/ai_gateway'
|
|
164
|
+
: '/openai/v1';
|
|
165
|
+
forwardBaseUrl = `${String(proxyBaseUrl || '').replace(/\/$/, '')}${providerPath}`;
|
|
166
|
+
baseUrl = `${String(localBaseUrl).replace(/\/$/, '')}/chat/egress/${encodeURIComponent(sessionId)}${providerPath}`;
|
|
167
|
+
tokenRef = `session-local-${sessionId}`;
|
|
168
|
+
tokenFingerprint = fingerprint(tokenRef);
|
|
169
|
+
} else if (method === 'byok') {
|
|
170
|
+
credential = resolveByokCredential(amalgmDir, { credentialId, harness, modelId });
|
|
171
|
+
tokenRef = credential?.key || byokKeyFor(harness, byok);
|
|
172
|
+
if (!tokenRef) {
|
|
173
|
+
throw new Error(`No BYOK credential configured for ${harness}`);
|
|
174
|
+
}
|
|
175
|
+
tokenFingerprint = fingerprint(tokenRef);
|
|
176
|
+
baseUrl = credential?.baseUrl || (
|
|
177
|
+
harness === 'claude_code'
|
|
178
|
+
? 'https://api.anthropic.com'
|
|
179
|
+
: harness === 'cursor'
|
|
180
|
+
? null
|
|
181
|
+
: harness === 'pi'
|
|
182
|
+
? 'https://ai-gateway.vercel.sh'
|
|
183
|
+
: 'https://api.openai.com/v1'
|
|
184
|
+
);
|
|
185
|
+
} else if (method === 'provider_auth') {
|
|
186
|
+
tokenFingerprint = providerAuthFingerprint(harness);
|
|
187
|
+
}
|
|
188
|
+
const runtimeHomePath = runtimeHome({ runtimeHomeRoot, harness, agentConfigId });
|
|
189
|
+
return {
|
|
190
|
+
method,
|
|
191
|
+
baseUrl,
|
|
192
|
+
credentialId: credential?.id || credentialId || null,
|
|
193
|
+
credentialName: credential?.name || null,
|
|
194
|
+
providerKind: credential?.providerKind || null,
|
|
195
|
+
forwardBaseUrl,
|
|
196
|
+
tokenRef,
|
|
197
|
+
tokenFingerprint,
|
|
198
|
+
runtimeHome: runtimeHomePath,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function safeBaseEnv(baseEnv = process.env) {
|
|
203
|
+
const env = {};
|
|
204
|
+
for (const key of ['PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'LANG', 'LC_ALL', 'TERM', 'TMPDIR']) {
|
|
205
|
+
if (baseEnv[key]) env[key] = baseEnv[key];
|
|
206
|
+
}
|
|
207
|
+
return env;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function runtimeEnv(contract, baseEnv = process.env) {
|
|
211
|
+
const env = contract.authMethod === 'provider_auth' ? { ...baseEnv } : safeBaseEnv(baseEnv);
|
|
212
|
+
for (const key of ['OPENAI_API_KEY', 'OPENAI_BASE_URL', 'ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL', 'AI_GATEWAY_API_KEY', 'AI_GATEWAY_BASE_URL', 'CURSOR_API_KEY', 'CURSOR_AUTH_TOKEN']) {
|
|
213
|
+
delete env[key];
|
|
214
|
+
}
|
|
215
|
+
if (contract.auth.runtimeHome) {
|
|
216
|
+
fs.mkdirSync(contract.auth.runtimeHome, { recursive: true });
|
|
217
|
+
env.HOME = contract.auth.runtimeHome;
|
|
218
|
+
env.CODEX_HOME = contract.auth.runtimeHome;
|
|
219
|
+
env.CLAUDE_CONFIG_DIR = contract.auth.runtimeHome;
|
|
220
|
+
env.OPENCODE_HOME = contract.auth.runtimeHome;
|
|
221
|
+
env.OPENCODE_CONFIG_DIR = contract.auth.runtimeHome;
|
|
222
|
+
if (contract.harness === 'opencode') {
|
|
223
|
+
env.XDG_CONFIG_HOME = path.join(contract.auth.runtimeHome, '.config');
|
|
224
|
+
env.XDG_DATA_HOME = path.join(contract.auth.runtimeHome, '.local', 'share');
|
|
225
|
+
env.XDG_STATE_HOME = path.join(contract.auth.runtimeHome, '.local', 'state');
|
|
226
|
+
env.XDG_CACHE_HOME = path.join(contract.auth.runtimeHome, '.cache');
|
|
227
|
+
}
|
|
228
|
+
if (contract.harness === 'claude_code' && contract.authMethod === 'provider_auth') {
|
|
229
|
+
delete env.CLAUDE_CONFIG_DIR;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
env.IS_SANDBOX = '1';
|
|
233
|
+
if (baseEnv.AMALGM_RUNTIME_TOKEN) env.AMALGM_RUNTIME_TOKEN = baseEnv.AMALGM_RUNTIME_TOKEN;
|
|
234
|
+
if (contract.authMethod === 'amalgm' || contract.authMethod === 'byok') {
|
|
235
|
+
if (contract.harness === 'claude_code') {
|
|
236
|
+
if (contract.authMethod === 'amalgm') {
|
|
237
|
+
env.ANTHROPIC_AUTH_TOKEN = contract.auth.tokenRef || '';
|
|
238
|
+
} else {
|
|
239
|
+
env.ANTHROPIC_API_KEY = contract.auth.tokenRef || '';
|
|
240
|
+
}
|
|
241
|
+
if (contract.auth.baseUrl) env.ANTHROPIC_BASE_URL = contract.auth.baseUrl;
|
|
242
|
+
} else if (contract.harness === 'cursor') {
|
|
243
|
+
env.CURSOR_API_KEY = contract.auth.tokenRef || '';
|
|
244
|
+
} else if (contract.harness === 'opencode') {
|
|
245
|
+
env.AI_GATEWAY_API_KEY = contract.auth.tokenRef || '';
|
|
246
|
+
if (contract.auth.baseUrl) env.AI_GATEWAY_BASE_URL = contract.auth.baseUrl;
|
|
247
|
+
} else if (contract.harness === 'pi') {
|
|
248
|
+
// Pi keys the vercel-ai-gateway provider on this env var; base-URL
|
|
249
|
+
// overrides ride through the generated egress extension instead.
|
|
250
|
+
env.AI_GATEWAY_API_KEY = contract.auth.tokenRef || '';
|
|
251
|
+
} else {
|
|
252
|
+
env.OPENAI_API_KEY = contract.auth.tokenRef || '';
|
|
253
|
+
if (contract.auth.baseUrl) env.OPENAI_BASE_URL = contract.auth.baseUrl;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return env;
|
|
257
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BYOK credential vault using AES-256-GCM with a key derived from a machine
|
|
3
|
+
* secret via scrypt.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { amalgmDir as resolveAmalgmDir } from '../lib/runtime-paths.js';
|
|
11
|
+
|
|
12
|
+
const STORE_VERSION = 1;
|
|
13
|
+
const CIPHER = 'aes-256-gcm';
|
|
14
|
+
const KEY_BYTES = 32;
|
|
15
|
+
const SALT_BYTES = 16;
|
|
16
|
+
const IV_BYTES = 12;
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_BASE_URL_BY_PROVIDER = {
|
|
19
|
+
anthropic_compatible: 'https://api.anthropic.com',
|
|
20
|
+
openai_compatible: 'https://api.openai.com/v1',
|
|
21
|
+
ai_gateway: 'https://ai-gateway.vercel.sh/v1',
|
|
22
|
+
cursor_api: '',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function credentialsPath(amalgmDir) {
|
|
26
|
+
return path.join(amalgmDir || resolveAmalgmDir(), '.credentials');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function machineSecret() {
|
|
30
|
+
const pieces = [
|
|
31
|
+
os.hostname(),
|
|
32
|
+
os.userInfo().username,
|
|
33
|
+
os.homedir(),
|
|
34
|
+
process.platform,
|
|
35
|
+
];
|
|
36
|
+
return pieces.filter(Boolean).join(':');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function deriveKey(salt) {
|
|
40
|
+
return crypto.scryptSync(machineSecret(), salt, KEY_BYTES);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function encryptSecret(plaintext) {
|
|
44
|
+
const salt = crypto.randomBytes(SALT_BYTES);
|
|
45
|
+
const iv = crypto.randomBytes(IV_BYTES);
|
|
46
|
+
const cipher = crypto.createCipheriv(CIPHER, deriveKey(salt), iv);
|
|
47
|
+
const data = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
|
|
48
|
+
return {
|
|
49
|
+
alg: CIPHER,
|
|
50
|
+
salt: salt.toString('base64url'),
|
|
51
|
+
iv: iv.toString('base64url'),
|
|
52
|
+
tag: cipher.getAuthTag().toString('base64url'),
|
|
53
|
+
data: data.toString('base64url'),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function decryptSecret(encrypted) {
|
|
58
|
+
if (!encrypted || encrypted.alg !== CIPHER) throw new Error('Unsupported credential encryption format');
|
|
59
|
+
const salt = Buffer.from(encrypted.salt, 'base64url');
|
|
60
|
+
const iv = Buffer.from(encrypted.iv, 'base64url');
|
|
61
|
+
const tag = Buffer.from(encrypted.tag, 'base64url');
|
|
62
|
+
const data = Buffer.from(encrypted.data, 'base64url');
|
|
63
|
+
const decipher = crypto.createDecipheriv(CIPHER, deriveKey(salt), iv);
|
|
64
|
+
decipher.setAuthTag(tag);
|
|
65
|
+
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function idFor() {
|
|
69
|
+
return `cred_${crypto.randomUUID().replace(/-/g, '').slice(0, 18)}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function keyHint(key) {
|
|
73
|
+
const clean = String(key || '').trim();
|
|
74
|
+
return clean ? `...${clean.slice(-4)}` : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function keyFingerprint(key) {
|
|
78
|
+
const clean = String(key || '').trim();
|
|
79
|
+
return clean ? crypto.createHash('sha256').update(clean).digest('hex').slice(0, 16) : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function normalizeBaseUrl(baseUrl, providerKind) {
|
|
83
|
+
const fallback = Object.prototype.hasOwnProperty.call(DEFAULT_BASE_URL_BY_PROVIDER, providerKind)
|
|
84
|
+
? DEFAULT_BASE_URL_BY_PROVIDER[providerKind]
|
|
85
|
+
: DEFAULT_BASE_URL_BY_PROVIDER.openai_compatible;
|
|
86
|
+
const raw = String(baseUrl || fallback).trim();
|
|
87
|
+
return raw.replace(/\/+$/, '');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function inferProviderKind({ baseUrl, key }) {
|
|
91
|
+
const url = String(baseUrl || '').toLowerCase();
|
|
92
|
+
const token = String(key || '').trim();
|
|
93
|
+
if (url.includes('anthropic') || token.startsWith('sk-ant-')) return 'anthropic_compatible';
|
|
94
|
+
if (url.includes('ai-gateway.vercel') || url.includes('gateway.vercel') || token.startsWith('v0_')) return 'ai_gateway';
|
|
95
|
+
return 'openai_compatible';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sanitizeCredential(record) {
|
|
99
|
+
return {
|
|
100
|
+
id: record.id,
|
|
101
|
+
name: record.name,
|
|
102
|
+
providerKind: record.providerKind,
|
|
103
|
+
keyHint: record.keyHint || null,
|
|
104
|
+
tokenFingerprint: record.tokenFingerprint || null,
|
|
105
|
+
createdAt: record.createdAt,
|
|
106
|
+
updatedAt: record.updatedAt,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function emptyStore() {
|
|
111
|
+
return { version: STORE_VERSION, credentials: [] };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function readStore(amalgmDir) {
|
|
115
|
+
const file = credentialsPath(amalgmDir);
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
118
|
+
if (!parsed || typeof parsed !== 'object') return emptyStore();
|
|
119
|
+
if (!Array.isArray(parsed.credentials)) return emptyStore();
|
|
120
|
+
return {
|
|
121
|
+
version: parsed.version || STORE_VERSION,
|
|
122
|
+
credentials: parsed.credentials,
|
|
123
|
+
};
|
|
124
|
+
} catch {
|
|
125
|
+
return emptyStore();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function writeStore(amalgmDir, store) {
|
|
130
|
+
const file = credentialsPath(amalgmDir);
|
|
131
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
132
|
+
fs.writeFileSync(file, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
|
|
133
|
+
try { fs.chmodSync(file, 0o600); } catch {}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function listCredentials(amalgmDir) {
|
|
137
|
+
return readStore(amalgmDir).credentials.map(sanitizeCredential);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function upsertCredential(amalgmDir, input = {}) {
|
|
141
|
+
const rawKey = String(input.key || input.apiKey || '').trim();
|
|
142
|
+
if (!rawKey) throw new Error('Key is required');
|
|
143
|
+
const providerKind = input.providerKind || inferProviderKind({ baseUrl: input.baseUrl, key: rawKey });
|
|
144
|
+
const baseUrl = normalizeBaseUrl(input.baseUrl, providerKind);
|
|
145
|
+
const name = String(input.name || '').trim() || defaultName(providerKind, baseUrl);
|
|
146
|
+
const now = new Date().toISOString();
|
|
147
|
+
const store = readStore(amalgmDir);
|
|
148
|
+
const id = String(input.id || '').trim() || idFor();
|
|
149
|
+
const existing = store.credentials.find((item) => item.id === id);
|
|
150
|
+
const record = {
|
|
151
|
+
id,
|
|
152
|
+
name,
|
|
153
|
+
providerKind,
|
|
154
|
+
baseUrl,
|
|
155
|
+
keyHint: keyHint(rawKey),
|
|
156
|
+
tokenFingerprint: keyFingerprint(rawKey),
|
|
157
|
+
encryptedKey: encryptSecret(rawKey),
|
|
158
|
+
createdAt: existing?.createdAt || now,
|
|
159
|
+
updatedAt: now,
|
|
160
|
+
};
|
|
161
|
+
const next = store.credentials.filter((item) => item.id !== id);
|
|
162
|
+
next.push(record);
|
|
163
|
+
writeStore(amalgmDir, { version: STORE_VERSION, credentials: next });
|
|
164
|
+
return sanitizeCredential(record);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function deleteCredential(amalgmDir, id) {
|
|
168
|
+
const store = readStore(amalgmDir);
|
|
169
|
+
const next = store.credentials.filter((item) => item.id !== id);
|
|
170
|
+
writeStore(amalgmDir, { version: STORE_VERSION, credentials: next });
|
|
171
|
+
return { ok: true, deleted: next.length !== store.credentials.length };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function providerKindsForHarness(harness) {
|
|
175
|
+
if (harness === 'claude_code') return ['anthropic_compatible'];
|
|
176
|
+
if (harness === 'codex') return ['openai_compatible'];
|
|
177
|
+
if (harness === 'cursor') return ['cursor_api'];
|
|
178
|
+
if (harness === 'opencode') return ['ai_gateway', 'openai_compatible', 'anthropic_compatible'];
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function resolveCredential(amalgmDir, { credentialId, harness, providerKind } = {}) {
|
|
183
|
+
const store = readStore(amalgmDir);
|
|
184
|
+
const allowed = providerKind ? [providerKind] : providerKindsForHarness(harness);
|
|
185
|
+
const record = credentialId
|
|
186
|
+
? store.credentials.find((item) => item.id === credentialId)
|
|
187
|
+
: store.credentials.find((item) => allowed.includes(item.providerKind));
|
|
188
|
+
if (!record) return null;
|
|
189
|
+
if (allowed.length && !allowed.includes(record.providerKind)) {
|
|
190
|
+
throw new Error(`Credential ${record.name || record.id} is not compatible with ${harness}`);
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
...sanitizeCredential(record),
|
|
194
|
+
baseUrl: record.baseUrl || normalizeBaseUrl('', record.providerKind),
|
|
195
|
+
key: decryptSecret(record.encryptedKey),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function defaultName(providerKind, baseUrl) {
|
|
200
|
+
if (providerKind === 'anthropic_compatible') return 'Anthropic';
|
|
201
|
+
if (providerKind === 'ai_gateway') return 'Vercel AI Gateway';
|
|
202
|
+
if (providerKind === 'cursor_api') return 'Cursor';
|
|
203
|
+
try {
|
|
204
|
+
const host = new URL(baseUrl).hostname.replace(/^api\./, '');
|
|
205
|
+
return host || 'OpenAI Compatible';
|
|
206
|
+
} catch {
|
|
207
|
+
return 'OpenAI Compatible';
|
|
208
|
+
}
|
|
209
|
+
}
|