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.
@@ -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
+ }
@@ -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
+ }