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,107 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Refresh src/models-snapshot.json from models.dev's SAP AI Core entry.
4
+ * Run: npm run update-models
5
+ */
6
+
7
+ import { writeFileSync } from "node:fs";
8
+ import { fileURLToPath } from "node:url";
9
+ import { dirname, join } from "node:path";
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const OUT = join(__dirname, "..", "src", "models-snapshot.json");
13
+ const SOURCE = "https://models.dev/api.json";
14
+
15
+ // SAP orchestration uses provider-native reasoning shapes (see
16
+ // src/stream.ts:reasoningParams). What's *common* across providers is the
17
+ // effort tier — pi has 5 above-off levels, the provider tiers are 3
18
+ // (low/medium/high). Fold minimal→low and xhigh→high so every pi level
19
+ // still does something (rather than dropping minimal/xhigh silently).
20
+ const SAP_EFFORT_BY_LEVEL = {
21
+ minimal: "low",
22
+ low: "low",
23
+ medium: "medium",
24
+ high: "high",
25
+ xhigh: "high",
26
+ };
27
+
28
+ function thinkingMapFor(reasoning) {
29
+ if (!reasoning) return undefined;
30
+ return { ...SAP_EFFORT_BY_LEVEL };
31
+ }
32
+
33
+ // Per-family reasoning support. SAP orchestration accepts Anthropic's
34
+ // `thinking + output_config` and OpenAI's `reasoning_effort`. Gemini's
35
+ // shape via SAP is undocumented — we leave reasoning OFF for gemini-* so
36
+ // pi's Shift+Tab cycle doesn't silently no-op. If/when SAP confirms the
37
+ // passthrough (likely `thinking_config.thinking_budget`), wire it in
38
+ // src/stream.ts:reasoningParams and re-enable here.
39
+ function supportsReasoning(model) {
40
+ if (!model.reasoning) return false;
41
+ if (model.id.startsWith("gemini-")) return false;
42
+ return true;
43
+ }
44
+
45
+ function adapt(model) {
46
+ const input = (model.modalities?.input ?? ["text"]).filter((m) =>
47
+ ["text", "image", "pdf"].includes(m),
48
+ );
49
+ const reasoning = supportsReasoning(model);
50
+ const adapted = {
51
+ id: model.id,
52
+ name: model.name ?? model.id,
53
+ reasoning,
54
+ tool_call: !!model.tool_call,
55
+ temperature: model.temperature !== false,
56
+ modalities: {
57
+ input,
58
+ output: ["text"],
59
+ },
60
+ limit: {
61
+ context: model.limit?.context ?? 0,
62
+ output: model.limit?.output ?? 0,
63
+ },
64
+ cost: {
65
+ input: model.cost?.input ?? 0,
66
+ output: model.cost?.output ?? 0,
67
+ cacheRead: model.cost?.cache_read ?? 0,
68
+ cacheWrite: model.cost?.cache_write ?? 0,
69
+ },
70
+ };
71
+ const thinkingMap = thinkingMapFor(reasoning);
72
+ if (thinkingMap) adapted.thinkingLevelMap = thinkingMap;
73
+ return adapted;
74
+ }
75
+
76
+ function shouldInclude(id) {
77
+ return (
78
+ id.startsWith("anthropic--claude-4") ||
79
+ id.startsWith("gpt-5") ||
80
+ id.startsWith("gemini-2.5")
81
+ );
82
+ }
83
+
84
+ const res = await fetch(SOURCE);
85
+ if (!res.ok) {
86
+ console.error(`Failed to fetch ${SOURCE}: ${res.status} ${res.statusText}`);
87
+ process.exit(1);
88
+ }
89
+ const all = await res.json();
90
+ const sapModels = all["sap-ai-core"]?.models ?? {};
91
+ const adapted = Object.values(sapModels)
92
+ .filter((m) => shouldInclude(m.id))
93
+ .map(adapt)
94
+ .sort((a, b) => a.id.localeCompare(b.id));
95
+
96
+ const snapshot = {
97
+ source: SOURCE,
98
+ fetchedAt: new Date().toISOString(),
99
+ count: adapted.length,
100
+ models: adapted,
101
+ };
102
+
103
+ writeFileSync(OUT, `${JSON.stringify(snapshot, null, 2)}\n`);
104
+ console.log(`Wrote ${adapted.length} models to ${OUT}`);
105
+ for (const m of adapted) {
106
+ console.log(` ${m.id} ctx=${m.limit.context} out=${m.limit.output} reasoning=${m.reasoning}`);
107
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,104 @@
1
+ import type { ProviderConfig } from "@earendil-works/pi-coding-agent";
2
+
3
+ /** A validated SAP AI Core service key: the raw JSON plus any embedded resource group. */
4
+ export type ValidatedKey = { raw: string; resourceGroup?: string };
5
+
6
+ // Fields a usable BTP service-key JSON must contain. `serviceurls.AI_API_URL`
7
+ // is a dot-path into a nested object.
8
+ const REQUIRED_FIELDS = [
9
+ "clientid",
10
+ "clientsecret",
11
+ "url",
12
+ "serviceurls.AI_API_URL",
13
+ ] as const;
14
+
15
+ /**
16
+ * Parse and validate a SAP BTP service-key JSON string. Throws an actionable
17
+ * error if it isn't valid JSON or is missing required fields. Returns the raw
18
+ * string alongside any non-standard `resourceGroup` baked into the key.
19
+ *
20
+ * Accepts an optional `resourceGroup` field on the key itself (non-standard but
21
+ * convenient for teams managing multiple groups); `AICORE_RESOURCE_GROUP` still
22
+ * wins at request time (see `resolveResourceGroup` in stream.ts).
23
+ */
24
+ export function parseAndValidateServiceKey(raw: string): ValidatedKey {
25
+ let parsed: unknown;
26
+ try {
27
+ parsed = JSON.parse(raw);
28
+ } catch {
29
+ throw new Error(
30
+ "SAP AI Core key must be the full BTP service-key JSON, not a " +
31
+ "plain string. Get it from BTP cockpit → AI Core service " +
32
+ `instance → Service Keys → View. Got: ${raw.slice(0, 40)}...`,
33
+ );
34
+ }
35
+
36
+ const missing = REQUIRED_FIELDS.filter((path) => {
37
+ const value = path
38
+ .split(".")
39
+ .reduce<unknown>(
40
+ (acc, segment) =>
41
+ acc && typeof acc === "object" && segment in (acc as object)
42
+ ? (acc as Record<string, unknown>)[segment]
43
+ : undefined,
44
+ parsed,
45
+ );
46
+ return typeof value !== "string" || value.length === 0;
47
+ });
48
+
49
+ if (missing.length > 0) {
50
+ throw new Error(
51
+ `SAP AI Core service-key JSON is missing required fields: ${missing.join(", ")}. ` +
52
+ "Make sure you pasted the entire service-key object from BTP cockpit.",
53
+ );
54
+ }
55
+
56
+ const fromKey =
57
+ typeof (parsed as Record<string, unknown>).resourceGroup === "string"
58
+ ? (parsed as Record<string, string>).resourceGroup
59
+ : undefined;
60
+ return { raw, resourceGroup: fromKey };
61
+ }
62
+
63
+ // pi 0.78 runs stored `type:"api_key"` credentials through a $-interpolating
64
+ // template engine (resolve-config-value.js), which corrupts any secret
65
+ // containing a literal `$` — and SAP service keys carry one in `clientsecret`.
66
+ // The `oauth` registration path is pi's escape hatch: a provider's
67
+ // `getApiKey()` return value is used verbatim and never passed through that
68
+ // engine (auth-storage.js). We aren't doing real OAuth here — `login` just
69
+ // captures and validates the pasted service-key JSON and stashes it in the
70
+ // persisted credential; `getApiKey` hands it back unchanged.
71
+ type SapOAuth = NonNullable<ProviderConfig["oauth"]>;
72
+
73
+ // Far-future expiry so pi never considers the credential stale and calls
74
+ // `refreshToken` (its check is `Date.now() >= expires`, always false here).
75
+ const NEVER_EXPIRES = Number.MAX_SAFE_INTEGER;
76
+
77
+ export const sapAiCoreOAuth: SapOAuth = {
78
+ name: "SAP AI Core",
79
+ async login(callbacks) {
80
+ const raw = (
81
+ await callbacks.onPrompt({
82
+ message:
83
+ "Paste your SAP BTP service-key JSON (single line) for AI Core",
84
+ placeholder: '{ "clientid": "…", "clientsecret": "…", … }',
85
+ })
86
+ ).trim();
87
+ // Validate up front so a malformed paste fails at /login, not on first chat.
88
+ parseAndValidateServiceKey(raw);
89
+ // `serviceKey` is a custom field (OAuthCredentials allows extra keys); the
90
+ // required refresh/access/expires fields are stubbed since this isn't a
91
+ // token flow.
92
+ return { serviceKey: raw, access: "", refresh: "", expires: NEVER_EXPIRES };
93
+ },
94
+ getApiKey(credentials) {
95
+ return typeof credentials.serviceKey === "string"
96
+ ? credentials.serviceKey
97
+ : "";
98
+ },
99
+ async refreshToken(credentials) {
100
+ // No tokens to refresh; unreachable given NEVER_EXPIRES, but the interface
101
+ // requires it. Return the credential unchanged.
102
+ return credentials;
103
+ },
104
+ };
@@ -0,0 +1,55 @@
1
+ import type { Api, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
2
+
3
+ // The subset of the Azure OpenAI chat-completion request body we set per turn.
4
+ // Field choices are dictated by what `@sap-ai-sdk/foundation-models`' request
5
+ // schema actually exposes (it pins Azure API version 2024-10-21):
6
+ // - `max_tokens` AND `max_completion_tokens` both exist; reasoning models
7
+ // (gpt-5*, o-series) reject `max_tokens` and require `max_completion_tokens`.
8
+ // - `reasoning_effort` is NOT in this schema/version, so depth is the model's
9
+ // own default and pi's reasoning-level selector is a no-op on this route
10
+ // (see note below). Orchestration remains the path for tuned effort.
11
+ // - `temperature` exists but gpt-5* reject it ("Unsupported parameter").
12
+ export type AzureOpenAiParams = {
13
+ max_tokens?: number;
14
+ max_completion_tokens?: number;
15
+ temperature?: number;
16
+ };
17
+
18
+ // Build the per-turn Azure OpenAI params for a foundation (direct) request.
19
+ //
20
+ // This is the foundation analogue of `buildLlmParams` in stream.ts, but far
21
+ // smaller: the direct Azure-OpenAI endpoint is OpenAI-only, so all of the
22
+ // Anthropic adaptive/budget-thinking branching collapses away.
23
+ //
24
+ // REASONING NOTE: gpt-5.5 still reasons here — it just reasons at its built-in
25
+ // default effort, because `reasoning_effort` isn't expressible against API
26
+ // version 2024-10-21. If a future SDK bump exposes it (or SAP accepts it as a
27
+ // passthrough field), add it here off `model.thinkingLevelMap[reasoning]`.
28
+ //
29
+ // VERIFY ON FIRST LIVE CALL: that gpt-5.5 accepts `max_completion_tokens` (and
30
+ // rejects `max_tokens`) on your tenant. If SAP's proxy unexpectedly wants
31
+ // `max_tokens` for this model, flip the branch below — the error will say so.
32
+ export function buildAzureOpenAiParams(
33
+ model: Model<Api>,
34
+ options: SimpleStreamOptions | undefined,
35
+ ): AzureOpenAiParams {
36
+ // Pi may pass a tighter budget than the model's hard cap (to reserve room
37
+ // for thinking). Respect it; otherwise use the model's documented max output.
38
+ const effectiveMaxTokens = options?.maxTokens ?? model.maxTokens;
39
+
40
+ const params: AzureOpenAiParams = {};
41
+ if (model.reasoning) {
42
+ params.max_completion_tokens = effectiveMaxTokens;
43
+ } else {
44
+ params.max_tokens = effectiveMaxTokens;
45
+ }
46
+
47
+ // Only forward temperature for models that accept it. gpt-5* reject it; the
48
+ // snapshot records `temperature:false`, but we gate on the id prefix here to
49
+ // stay self-contained (mirrors `modelSupportsTemperature` in stream.ts).
50
+ if (options?.temperature !== undefined && !model.id.startsWith("gpt-5")) {
51
+ params.temperature = options.temperature;
52
+ }
53
+
54
+ return params;
55
+ }
@@ -0,0 +1,93 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { dirname, join } from "node:path";
4
+
5
+ type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
6
+
7
+ export type SapModel = {
8
+ id: string;
9
+ name: string;
10
+ reasoning: boolean;
11
+ tool_call: boolean;
12
+ temperature: boolean;
13
+ modalities: {
14
+ input: ("text" | "image" | "pdf")[];
15
+ output: ("text")[];
16
+ };
17
+ limit: {
18
+ context: number;
19
+ output: number;
20
+ };
21
+ cost: {
22
+ input: number;
23
+ output: number;
24
+ cacheRead: number;
25
+ cacheWrite: number;
26
+ };
27
+ thinkingLevelMap?: Partial<Record<ThinkingLevel, string | null>>;
28
+ };
29
+
30
+ // Tenant-specific or pre-release models not yet in models.dev's SAP catalog.
31
+ // Anything in your SAP tenant that the snapshot doesn't include — add here.
32
+ // User-side additions (per-machine, not in source control) should go in
33
+ // ~/.pi/agent/models.json using pi's built-in custom-models mechanism.
34
+ // SAP orchestration unifies reasoning across providers as
35
+ // output_config.effort: "low" | "medium" | "high". See scripts/update-models.mjs
36
+ // and stream.ts for the full mapping rationale.
37
+ const SAP_EFFORT: SapModel["thinkingLevelMap"] = {
38
+ minimal: "low",
39
+ low: "low",
40
+ medium: "medium",
41
+ high: "high",
42
+ xhigh: "high",
43
+ };
44
+
45
+ // Currently empty — models.dev's SAP catalog covers everything in our
46
+ // tenant. Add entries here when SAP exposes a tenant-only or pre-release
47
+ // model that hasn't landed in the public catalog yet, e.g.:
48
+ //
49
+ // {
50
+ // id: "some-preview-model",
51
+ // name: "Some Preview Model",
52
+ // reasoning: true,
53
+ // tool_call: true,
54
+ // temperature: true,
55
+ // modalities: { input: ["text"], output: ["text"] },
56
+ // limit: { context: 200_000, output: 32_000 },
57
+ // cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
58
+ // thinkingLevelMap: SAP_EFFORT,
59
+ // },
60
+ const TENANT_EXTRAS: SapModel[] = [];
61
+
62
+ function loadSnapshot(): SapModel[] {
63
+ const snapshotPath = join(
64
+ dirname(fileURLToPath(import.meta.url)),
65
+ "models-snapshot.json",
66
+ );
67
+ const raw = readFileSync(snapshotPath, "utf8");
68
+ const parsed = JSON.parse(raw) as { models?: SapModel[] };
69
+ return parsed.models ?? [];
70
+ }
71
+
72
+ const SNAPSHOT_MODELS = loadSnapshot();
73
+
74
+ // Merge: snapshot first, then extras (extras win on duplicate id).
75
+ const byId = new Map<string, SapModel>();
76
+ for (const m of SNAPSHOT_MODELS) byId.set(m.id, m);
77
+ for (const m of TENANT_EXTRAS) byId.set(m.id, m);
78
+
79
+ export const MODELS: SapModel[] = Array.from(byId.values()).sort((a, b) =>
80
+ a.id.localeCompare(b.id),
81
+ );
82
+
83
+ // Models exposed via the direct *foundation* (Azure OpenAI) provider, which
84
+ // routes through a per-model SAP AI Core deployment instead of orchestration.
85
+ // List ONLY ids you've created a foundation-models deployment for — SAP needs
86
+ // one deployment per (model, version, resource group), and an id with no
87
+ // deployment 404s at call time. Definitions (cost/limits/modalities) are reused
88
+ // from the shared snapshot above, so an id only has to be present there.
89
+ const FOUNDATION_MODEL_IDS = new Set(["gpt-5.5"]);
90
+
91
+ export const FOUNDATION_MODELS: SapModel[] = MODELS.filter((m) =>
92
+ FOUNDATION_MODEL_IDS.has(m.id),
93
+ );