pi-sap-aicore 0.1.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/LICENSE +21 -0
- package/README.md +296 -0
- package/index.ts +68 -0
- package/package.json +40 -0
- package/scripts/diagnose-streaming.mjs +99 -0
- package/scripts/list-sap-models.mjs +92 -0
- package/scripts/update-models.mjs +107 -0
- package/src/auth.ts +104 -0
- package/src/foundation-params.ts +55 -0
- package/src/models-config.ts +93 -0
- package/src/models-snapshot.json +527 -0
- package/src/stream-foundation.ts +361 -0
- package/src/stream.ts +1051 -0
- package/src/to-pi-model.ts +21 -0
- package/src/translate-foundation.ts +154 -0
- package/src/translate.ts +218 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Api } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { SapModel } from "./models-config.ts";
|
|
4
|
+
|
|
5
|
+
export function toPiModel(model: SapModel, api: Api): ProviderModelConfig {
|
|
6
|
+
const input = model.modalities.input.filter(
|
|
7
|
+
(m): m is "text" | "image" => m === "text" || m === "image",
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
id: model.id,
|
|
12
|
+
name: model.name,
|
|
13
|
+
api,
|
|
14
|
+
reasoning: model.reasoning,
|
|
15
|
+
input,
|
|
16
|
+
cost: model.cost,
|
|
17
|
+
contextWindow: model.limit.context,
|
|
18
|
+
maxTokens: model.limit.output,
|
|
19
|
+
thinkingLevelMap: model.thinkingLevelMap,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AssistantMessage,
|
|
3
|
+
Context,
|
|
4
|
+
Message,
|
|
5
|
+
TextContent,
|
|
6
|
+
Tool,
|
|
7
|
+
ToolResultMessage,
|
|
8
|
+
UserMessage,
|
|
9
|
+
} from "@earendil-works/pi-ai";
|
|
10
|
+
import type {
|
|
11
|
+
AzureOpenAiChatCompletionRequestAssistantMessage,
|
|
12
|
+
AzureOpenAiChatCompletionRequestMessage,
|
|
13
|
+
AzureOpenAiChatCompletionRequestToolMessage,
|
|
14
|
+
AzureOpenAiChatCompletionRequestUserMessage,
|
|
15
|
+
AzureOpenAiChatCompletionTool,
|
|
16
|
+
} from "@sap-ai-sdk/foundation-models";
|
|
17
|
+
|
|
18
|
+
// pi `Context` → Azure OpenAI chat request. This is the orchestration
|
|
19
|
+
// `translate.ts` minus the Anthropic `cache_control` tagging — that is an
|
|
20
|
+
// Anthropic-via-orchestration concern and has no meaning on the direct
|
|
21
|
+
// OpenAI endpoint, so the foundation path is strictly simpler. The Azure
|
|
22
|
+
// message/content/tool shapes are the standard OpenAI ones (each carries an
|
|
23
|
+
// `& Record<string, any>` escape hatch), so inline literals type-check
|
|
24
|
+
// against the message-level types without importing the content-part types
|
|
25
|
+
// (which the package doesn't re-export from its root).
|
|
26
|
+
export function piContextToAzureOpenAi(context: Context): {
|
|
27
|
+
messages: AzureOpenAiChatCompletionRequestMessage[];
|
|
28
|
+
tools: AzureOpenAiChatCompletionTool[];
|
|
29
|
+
} {
|
|
30
|
+
const messages: AzureOpenAiChatCompletionRequestMessage[] = [];
|
|
31
|
+
|
|
32
|
+
if (context.systemPrompt) {
|
|
33
|
+
messages.push({ role: "system", content: context.systemPrompt });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const msg of context.messages) {
|
|
37
|
+
messages.push(...piMessageToAzureOpenAi(msg));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const tools = (context.tools ?? []).map(piToolToAzureOpenAi);
|
|
41
|
+
return { messages, tools };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function piMessageToAzureOpenAi(
|
|
45
|
+
msg: Message,
|
|
46
|
+
): AzureOpenAiChatCompletionRequestMessage[] {
|
|
47
|
+
switch (msg.role) {
|
|
48
|
+
case "user":
|
|
49
|
+
return [piUserToAzureOpenAi(msg)];
|
|
50
|
+
case "assistant":
|
|
51
|
+
return [piAssistantToAzureOpenAi(msg)];
|
|
52
|
+
case "toolResult":
|
|
53
|
+
return piToolResultToAzureOpenAi(msg);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function piUserToAzureOpenAi(
|
|
58
|
+
msg: UserMessage,
|
|
59
|
+
): AzureOpenAiChatCompletionRequestUserMessage {
|
|
60
|
+
if (typeof msg.content === "string") {
|
|
61
|
+
return { role: "user", content: msg.content };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const items = msg.content.map((part) =>
|
|
65
|
+
part.type === "text"
|
|
66
|
+
? { type: "text" as const, text: part.text }
|
|
67
|
+
: {
|
|
68
|
+
type: "image_url" as const,
|
|
69
|
+
image_url: { url: `data:${part.mimeType};base64,${part.data}` },
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
return { role: "user", content: items };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function piAssistantToAzureOpenAi(
|
|
76
|
+
msg: AssistantMessage,
|
|
77
|
+
): AzureOpenAiChatCompletionRequestAssistantMessage {
|
|
78
|
+
let text = "";
|
|
79
|
+
const toolCalls: {
|
|
80
|
+
id: string;
|
|
81
|
+
type: "function";
|
|
82
|
+
function: { name: string; arguments: string };
|
|
83
|
+
}[] = [];
|
|
84
|
+
|
|
85
|
+
for (const block of msg.content) {
|
|
86
|
+
if (block.type === "text") {
|
|
87
|
+
text += block.text;
|
|
88
|
+
} else if (block.type === "toolCall") {
|
|
89
|
+
toolCalls.push({
|
|
90
|
+
id: block.id,
|
|
91
|
+
type: "function",
|
|
92
|
+
function: {
|
|
93
|
+
name: block.name,
|
|
94
|
+
arguments: JSON.stringify(block.arguments),
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// OpenAI rejects an assistant message with neither content nor tool_calls.
|
|
101
|
+
// Match the orchestration path: substitute a single space when there is no
|
|
102
|
+
// text and no tool call, so conversation alternation stays 1:1 with pi's log.
|
|
103
|
+
const result: AzureOpenAiChatCompletionRequestAssistantMessage = {
|
|
104
|
+
role: "assistant",
|
|
105
|
+
content: text || (toolCalls.length === 0 ? " " : ""),
|
|
106
|
+
};
|
|
107
|
+
if (toolCalls.length > 0) result.tool_calls = toolCalls;
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function piToolResultToAzureOpenAi(
|
|
112
|
+
msg: ToolResultMessage,
|
|
113
|
+
): AzureOpenAiChatCompletionRequestMessage[] {
|
|
114
|
+
const text = msg.content
|
|
115
|
+
.filter((part): part is TextContent => part.type === "text")
|
|
116
|
+
.map((part) => part.text)
|
|
117
|
+
.join("\n");
|
|
118
|
+
|
|
119
|
+
const toolMessage: AzureOpenAiChatCompletionRequestToolMessage = {
|
|
120
|
+
role: "tool",
|
|
121
|
+
tool_call_id: msg.toolCallId,
|
|
122
|
+
content: text,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// The tool message schema is text-only, so image blocks produced by pi
|
|
126
|
+
// tools (e.g. `read` on an image) are hoisted into a synthetic user message
|
|
127
|
+
// right after the tool result so vision-capable models still see the bytes.
|
|
128
|
+
const images = msg.content.filter(
|
|
129
|
+
(part): part is { type: "image"; data: string; mimeType: string } =>
|
|
130
|
+
part.type === "image",
|
|
131
|
+
);
|
|
132
|
+
if (images.length === 0) return [toolMessage];
|
|
133
|
+
|
|
134
|
+
const imageItems = images.map((img) => ({
|
|
135
|
+
type: "image_url" as const,
|
|
136
|
+
image_url: { url: `data:${img.mimeType};base64,${img.data}` },
|
|
137
|
+
}));
|
|
138
|
+
const imageMessage: AzureOpenAiChatCompletionRequestUserMessage = {
|
|
139
|
+
role: "user",
|
|
140
|
+
content: imageItems,
|
|
141
|
+
};
|
|
142
|
+
return [toolMessage, imageMessage];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function piToolToAzureOpenAi(tool: Tool): AzureOpenAiChatCompletionTool {
|
|
146
|
+
return {
|
|
147
|
+
type: "function",
|
|
148
|
+
function: {
|
|
149
|
+
name: tool.name,
|
|
150
|
+
description: tool.description,
|
|
151
|
+
parameters: tool.parameters as unknown as Record<string, unknown>,
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
package/src/translate.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AssistantMessage,
|
|
3
|
+
Context,
|
|
4
|
+
Message,
|
|
5
|
+
TextContent,
|
|
6
|
+
Tool,
|
|
7
|
+
ToolResultMessage,
|
|
8
|
+
UserMessage,
|
|
9
|
+
} from "@earendil-works/pi-ai";
|
|
10
|
+
import type {
|
|
11
|
+
AssistantChatMessage,
|
|
12
|
+
ChatCompletionTool,
|
|
13
|
+
ChatMessage,
|
|
14
|
+
UserChatMessageContent,
|
|
15
|
+
UserChatMessageContentItem,
|
|
16
|
+
} from "@sap-ai-sdk/orchestration";
|
|
17
|
+
|
|
18
|
+
// Anthropic prompt caching via SAP orchestration is undocumented. SAP's
|
|
19
|
+
// ChatMessage schemas are strictly typed (no Record<string,any> escape
|
|
20
|
+
// hatch on content), `cache_control` appears nowhere in
|
|
21
|
+
// @sap-ai-sdk/orchestration, and the orchestration server may reject
|
|
22
|
+
// unknown fields with a 400. Opt-in via PI_SAP_AICORE_CACHE_CONTROL=1 so
|
|
23
|
+
// users can probe their own tenant without forcing the risk on everyone
|
|
24
|
+
// — if SAP accepts it, `cacheRead`/`cacheWrite` in the Usage block start
|
|
25
|
+
// reporting non-zero numbers and pi's cost line drops ~10× on cached
|
|
26
|
+
// turns. If SAP rejects it, the error chain will say so.
|
|
27
|
+
const CACHE_CONTROL_ENABLED =
|
|
28
|
+
process.env.PI_SAP_AICORE_CACHE_CONTROL === "1";
|
|
29
|
+
|
|
30
|
+
type CacheControl = { type: "ephemeral" };
|
|
31
|
+
const EPHEMERAL: CacheControl = { type: "ephemeral" };
|
|
32
|
+
|
|
33
|
+
export function piContextToOrchestration(context: Context): {
|
|
34
|
+
messages: ChatMessage[];
|
|
35
|
+
tools: ChatCompletionTool[];
|
|
36
|
+
} {
|
|
37
|
+
const messages: ChatMessage[] = [];
|
|
38
|
+
|
|
39
|
+
if (context.systemPrompt) {
|
|
40
|
+
messages.push(
|
|
41
|
+
tagCacheControl(
|
|
42
|
+
{ role: "system", content: context.systemPrompt },
|
|
43
|
+
CACHE_CONTROL_ENABLED,
|
|
44
|
+
),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const pi = context.messages;
|
|
49
|
+
// Anthropic caches up to 4 breakpoints; tagging the LAST user message
|
|
50
|
+
// (after the system prompt) is the standard "keep the long prefix
|
|
51
|
+
// cached" pattern. We tag at most 1 here for safety; expand later
|
|
52
|
+
// once SAP behaviour is confirmed.
|
|
53
|
+
const lastUserIdx = lastIndexWhere(pi, (m) => m.role === "user");
|
|
54
|
+
for (let i = 0; i < pi.length; i++) {
|
|
55
|
+
const translated = piMessageToOrchestration(pi[i]);
|
|
56
|
+
const tagLast = CACHE_CONTROL_ENABLED && i === lastUserIdx;
|
|
57
|
+
if (tagLast && translated.length > 0) {
|
|
58
|
+
translated[translated.length - 1] = tagCacheControl(
|
|
59
|
+
translated[translated.length - 1],
|
|
60
|
+
true,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
messages.push(...translated);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const tools = (context.tools ?? []).map(piToolToOrchestration);
|
|
67
|
+
|
|
68
|
+
return { messages, tools };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function lastIndexWhere<T>(arr: T[], pred: (t: T) => boolean): number {
|
|
72
|
+
for (let i = arr.length - 1; i >= 0; i--) if (pred(arr[i])) return i;
|
|
73
|
+
return -1;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Tag a translated message's last text content with Anthropic's
|
|
77
|
+
// `cache_control: {type: "ephemeral"}`. Casts through `any` because
|
|
78
|
+
// SAP's typings forbid it (Anthropic-native field that SAP doesn't
|
|
79
|
+
// expose in its schema — see note at top of file).
|
|
80
|
+
function tagCacheControl(msg: ChatMessage, enabled: boolean): ChatMessage {
|
|
81
|
+
if (!enabled) return msg;
|
|
82
|
+
if (typeof msg.content === "string") {
|
|
83
|
+
return {
|
|
84
|
+
...msg,
|
|
85
|
+
content: [
|
|
86
|
+
{ type: "text", text: msg.content, cache_control: EPHEMERAL } as any,
|
|
87
|
+
],
|
|
88
|
+
} as ChatMessage;
|
|
89
|
+
}
|
|
90
|
+
if (Array.isArray(msg.content) && msg.content.length > 0) {
|
|
91
|
+
const items = msg.content.slice();
|
|
92
|
+
const last = items[items.length - 1] as any;
|
|
93
|
+
items[items.length - 1] = { ...last, cache_control: EPHEMERAL };
|
|
94
|
+
return { ...msg, content: items } as ChatMessage;
|
|
95
|
+
}
|
|
96
|
+
return msg;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function piMessageToOrchestration(msg: Message): ChatMessage[] {
|
|
100
|
+
switch (msg.role) {
|
|
101
|
+
case "user":
|
|
102
|
+
return [piUserToOrchestration(msg)];
|
|
103
|
+
case "assistant":
|
|
104
|
+
return [piAssistantToOrchestration(msg)];
|
|
105
|
+
case "toolResult":
|
|
106
|
+
return piToolResultToOrchestration(msg);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function piUserToOrchestration(msg: UserMessage): ChatMessage {
|
|
111
|
+
if (typeof msg.content === "string") {
|
|
112
|
+
return { role: "user", content: msg.content };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const items: UserChatMessageContentItem[] = msg.content.map((part) => {
|
|
116
|
+
if (part.type === "text") {
|
|
117
|
+
return { type: "text", text: part.text };
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
type: "image_url",
|
|
121
|
+
image_url: { url: `data:${part.mimeType};base64,${part.data}` },
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return { role: "user", content: items as UserChatMessageContent };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function piAssistantToOrchestration(msg: AssistantMessage): ChatMessage {
|
|
129
|
+
let text = "";
|
|
130
|
+
const toolCalls: NonNullable<AssistantChatMessage["tool_calls"]> = [];
|
|
131
|
+
|
|
132
|
+
for (const block of msg.content) {
|
|
133
|
+
if (block.type === "text") {
|
|
134
|
+
text += block.text;
|
|
135
|
+
} else if (block.type === "toolCall") {
|
|
136
|
+
toolCalls.push({
|
|
137
|
+
id: block.id,
|
|
138
|
+
type: "function",
|
|
139
|
+
function: {
|
|
140
|
+
name: block.name,
|
|
141
|
+
arguments: JSON.stringify(block.arguments),
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Bedrock (which SAP orchestration wraps) rejects assistant messages with
|
|
148
|
+
// no text AND no tool_calls — "Assistant message has neither text nor
|
|
149
|
+
// tool_use blocks." Pi can produce these when a prior stream was
|
|
150
|
+
// interrupted or the turn contained only block types we don't translate
|
|
151
|
+
// (e.g. reasoning-only). Substitute a single space so the message
|
|
152
|
+
// validates while preserving conversation alternation 1:1 with pi's log.
|
|
153
|
+
const result: AssistantChatMessage = {
|
|
154
|
+
role: "assistant",
|
|
155
|
+
content: text || (toolCalls.length === 0 ? " " : ""),
|
|
156
|
+
};
|
|
157
|
+
if (toolCalls.length > 0) result.tool_calls = toolCalls;
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function piToolResultToOrchestration(msg: ToolResultMessage): ChatMessage[] {
|
|
162
|
+
const text = msg.content
|
|
163
|
+
.filter((part): part is TextContent => part.type === "text")
|
|
164
|
+
.map((part) => part.text)
|
|
165
|
+
.join("\n");
|
|
166
|
+
|
|
167
|
+
const toolMessage: ChatMessage = {
|
|
168
|
+
role: "tool",
|
|
169
|
+
tool_call_id: msg.toolCallId,
|
|
170
|
+
content: text,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// SAP's ToolChatMessage.content schema is text-only (`string |
|
|
174
|
+
// TextContent[]`), so any image blocks produced by pi tools (most
|
|
175
|
+
// commonly the `read` tool on an image file) get silently dropped.
|
|
176
|
+
// Hoist them into a synthetic user message immediately after the
|
|
177
|
+
// tool result so vision-capable models actually see the bytes.
|
|
178
|
+
const images = msg.content.filter(
|
|
179
|
+
(part): part is { type: "image"; data: string; mimeType: string } =>
|
|
180
|
+
part.type === "image",
|
|
181
|
+
);
|
|
182
|
+
if (images.length === 0) return [toolMessage];
|
|
183
|
+
|
|
184
|
+
const imageItems: UserChatMessageContentItem[] = images.map((img) => ({
|
|
185
|
+
type: "image_url",
|
|
186
|
+
image_url: { url: `data:${img.mimeType};base64,${img.data}` },
|
|
187
|
+
}));
|
|
188
|
+
const imageMessage: ChatMessage = {
|
|
189
|
+
role: "user",
|
|
190
|
+
content: imageItems as UserChatMessageContent,
|
|
191
|
+
};
|
|
192
|
+
return [toolMessage, imageMessage];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function piToolToOrchestration(tool: Tool): ChatCompletionTool {
|
|
196
|
+
return {
|
|
197
|
+
type: "function",
|
|
198
|
+
function: {
|
|
199
|
+
name: tool.name,
|
|
200
|
+
description: tool.description,
|
|
201
|
+
parameters: tool.parameters as unknown as Record<string, unknown>,
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function mapFinishReason(
|
|
207
|
+
reason: string | undefined,
|
|
208
|
+
): "stop" | "length" | "toolUse" {
|
|
209
|
+
switch (reason) {
|
|
210
|
+
case "length":
|
|
211
|
+
return "length";
|
|
212
|
+
case "tool_calls":
|
|
213
|
+
case "function_call":
|
|
214
|
+
return "toolUse";
|
|
215
|
+
default:
|
|
216
|
+
return "stop";
|
|
217
|
+
}
|
|
218
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"resolveJsonModule": true,
|
|
10
|
+
"noEmit": true,
|
|
11
|
+
"isolatedModules": true,
|
|
12
|
+
"allowImportingTsExtensions": true,
|
|
13
|
+
"verbatimModuleSyntax": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["index.ts", "src/**/*.ts"]
|
|
16
|
+
}
|