From 58bbe900bcefae243e9c5d18240f38b1aa7dd7bb Mon Sep 17 00:00:00 2001 From: Chandra Date: Wed, 26 Aug 2026 22:00:31 +0530 Subject: [PATCH] fix(sap-ai-core): normalize finish_reason and strip assistant prefill --- .../core/src/plugin/provider/sap-ai-core.ts | 41 +++++++++- .../test/plugin/provider-sap-ai-core.test.ts | 42 +++++++++- packages/opencode/src/provider/provider.ts | 29 ++++++- packages/opencode/src/provider/transform.ts | 8 ++ .../test/provider/sap-ai-core.test.ts | 82 +++++++++++++++++++ .../opencode/test/provider/transform.test.ts | 71 ++++++++++++++++ 6 files changed, 266 insertions(+), 7 deletions(-) create mode 100644 packages/opencode/test/provider/sap-ai-core.test.ts diff --git a/packages/core/src/plugin/provider/sap-ai-core.ts b/packages/core/src/plugin/provider/sap-ai-core.ts index 8c668d8b4147..72e135f01f6d 100644 --- a/packages/core/src/plugin/provider/sap-ai-core.ts +++ b/packages/core/src/plugin/provider/sap-ai-core.ts @@ -4,6 +4,36 @@ import { define } from "../internal" import { Npm } from "../../npm" import { ProviderV2 } from "../../provider" +type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise + +export function sapAICoreFetch(upstream: FetchLike = fetch) { + return async (url: string | URL | Request, init?: RequestInit): Promise => { + const response = await upstream(url, init) + if (response.body && response.headers.get("content-type")?.includes("text/event-stream")) { + const reader = response.body.getReader() + const encoder = new TextEncoder() + const decoder = new TextDecoder() + const stream = new ReadableStream({ + async pull(ctrl) { + const { done, value } = await reader.read() + if (done) { + ctrl.close() + return + } + let text = decoder.decode(value, { stream: true }) + text = text.replace(/"finish_reason"\s*:\s*null/g, '"finish_reason":"stop"') + ctrl.enqueue(encoder.encode(text)) + }, + cancel() { + reader.cancel() + }, + }) + return new Response(stream, { headers: response.headers, status: response.status }) + } + return response + } +} + export const SapAICorePlugin = define({ id: "sap-ai-core", effect: Effect.fn(function* (ctx) { @@ -29,11 +59,14 @@ export const SapAICorePlugin = define({ const match = Object.keys(mod).find((name) => name.startsWith("create")) if (!match) throw new Error(`Package ${evt.package} has no provider factory export`) - evt.sdk = mod[match]( - serviceKey + const upstream = typeof evt.options.fetch === "function" ? (evt.options.fetch as FetchLike) : undefined + evt.sdk = mod[match]({ + ...evt.options, + ...(serviceKey ? { deploymentId: process.env.AICORE_DEPLOYMENT_ID, resourceGroup: process.env.AICORE_RESOURCE_GROUP } - : {}, - ) + : {}), + fetch: sapAICoreFetch(upstream) as typeof fetch, + }) }), ) yield* ctx.aisdk.language( diff --git a/packages/core/test/plugin/provider-sap-ai-core.test.ts b/packages/core/test/plugin/provider-sap-ai-core.test.ts index 5056c5dbb7ef..e8def014cd00 100644 --- a/packages/core/test/plugin/provider-sap-ai-core.test.ts +++ b/packages/core/test/plugin/provider-sap-ai-core.test.ts @@ -1,11 +1,11 @@ import { AISDK } from "@opencode-ai/core/aisdk" -import { describe, expect } from "bun:test" +import { describe, expect, it as bun_it } from "bun:test" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" import { Npm } from "@opencode-ai/core/npm" -import { SapAICorePlugin } from "@opencode-ai/core/plugin/provider/sap-ai-core" +import { SapAICorePlugin, sapAICoreFetch } from "@opencode-ai/core/plugin/provider/sap-ai-core" import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" @@ -157,3 +157,41 @@ describe("SapAICorePlugin", () => { ), ) }) + +type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise + +describe("sapAICoreFetch", () => { + bun_it("normalizes finish_reason: null to finish_reason: 'stop' in SSE stream", async () => { + const chunk = `data: {"choices":[{"delta":{"content":"Hello!"},"finish_reason":null,"index":0}]}\n\n` + const upstream: FetchLike = async () => + new Response( + new ReadableStream({ + start: (ctrl) => { + ctrl.enqueue(new TextEncoder().encode(chunk)) + ctrl.close() + }, + }), + { + status: 200, + headers: { "content-type": "text/event-stream" }, + }, + ) + const response = await sapAICoreFetch(upstream)("https://test", {}) + const text = await response.text() + expect(text).toContain('"finish_reason":"stop"') + expect(text).not.toContain('"finish_reason":null') + }) + + bun_it("passes through non-SSE responses unchanged", async () => { + const upstream: FetchLike = async () => + new Response(JSON.stringify({ result: "ok" }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + const response = await sapAICoreFetch(upstream)("https://test", {}) + expect(response.status).toBe(200) + const data = await response.json() + expect(data).toEqual({ result: "ok" }) + }) +}) + diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 0f8cbd23f775..b93c5f651ef2 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -590,7 +590,34 @@ function custom(dep: CustomDep): Record { return { autoload: !!envServiceKey, - options: envServiceKey ? { deploymentId, resourceGroup } : {}, + options: { + ...(envServiceKey ? { deploymentId, resourceGroup } : {}), + fetch: async (url: RequestInfo | URL, init?: RequestInit) => { + const response = await fetch(url, init) + if (response.body && response.headers.get("content-type")?.includes("text/event-stream")) { + const reader = response.body.getReader() + const encoder = new TextEncoder() + const decoder = new TextDecoder() + const stream = new ReadableStream({ + async pull(ctrl) { + const { done, value } = await reader.read() + if (done) { + ctrl.close() + return + } + let text = decoder.decode(value, { stream: true }) + text = text.replace(/"finish_reason"\s*:\s*null/g, '"finish_reason":"stop"') + ctrl.enqueue(encoder.encode(text)) + }, + cancel() { + reader.cancel() + }, + }) + return new Response(stream, { headers: response.headers, status: response.status }) + } + return response + }, + }, async getModel(sdk: any, modelID: string) { return sdk(modelID) }, diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 0667fc2eb098..428972f0c0e6 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -353,6 +353,14 @@ function normalizeMessages( }) } + // SAP AI Core Orchestration API requires conversations to end with a user message + // and does not support assistant message prefill. + if (model.providerID === "sap-ai-core" || model.api.npm === "@jerome-benoit/sap-ai-provider-v2") { + while (msgs.length > 0 && msgs[msgs.length - 1].role === "assistant") { + msgs = msgs.slice(0, -1) + } + } + return msgs } diff --git a/packages/opencode/test/provider/sap-ai-core.test.ts b/packages/opencode/test/provider/sap-ai-core.test.ts new file mode 100644 index 000000000000..62f4b48060c0 --- /dev/null +++ b/packages/opencode/test/provider/sap-ai-core.test.ts @@ -0,0 +1,82 @@ +import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Provider } from "../../src/provider/provider" +import { Effect } from "effect" +import { testEffect } from "../lib/effect" +import { ProviderV2 } from "@opencode-ai/core/provider" + +const SAP_AI_CORE = ProviderV2.ID.make("sap-ai-core") +const it = testEffect(LayerNode.compile(Provider.node)) + +const withEnv = (values: Record, effect: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(values).map((key) => [key, process.env[key]] as const)) + Object.entries(values).forEach(([key, value]) => { + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + return previous + }), + () => effect, + (previous) => + Effect.sync(() => { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + }), + ) + +describe("sap-ai-core provider", () => { + it.instance( + "autoloads when AICORE_SERVICE_KEY is set and configures fetch wrapper", + () => + withEnv( + { + AICORE_SERVICE_KEY: '{"serviceUrl":"https://test","clientId":"id","clientSecret":"secret"}', + AICORE_DEPLOYMENT_ID: "deploy-123", + AICORE_RESOURCE_GROUP: "default", + }, + Effect.gen(function* () { + const provider = yield* Provider.Service + const providers = yield* provider.list() + expect(providers[SAP_AI_CORE]).toBeDefined() + expect(providers[SAP_AI_CORE].options?.deploymentId).toBe("deploy-123") + expect(providers[SAP_AI_CORE].options?.resourceGroup).toBe("default") + expect(typeof providers[SAP_AI_CORE].options?.fetch).toBe("function") + + // Test the fetch wrapper normalizes finish_reason in SSE stream + const customFetch = providers[SAP_AI_CORE].options!.fetch + const chunk = `data: {"choices":[{"delta":{"content":"Hi!"},"finish_reason":null,"index":0}]}\n\n` + + yield* Effect.promise(async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = (async () => + new Response( + new ReadableStream({ + start(ctrl) { + ctrl.enqueue(new TextEncoder().encode(chunk)) + ctrl.close() + }, + }), + { + status: 200, + headers: { "content-type": "text/event-stream" }, + }, + )) as any + + try { + const res = await customFetch("https://api.test/v1/chat") + const text = await res.text() + expect(text).toContain('"finish_reason":"stop"') + expect(text).not.toContain('"finish_reason":null') + } finally { + globalThis.fetch = originalFetch + } + }) + }), + ), + { config: {} }, + ) +}) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 97f0de281483..d847df3d4610 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -5680,3 +5680,74 @@ describe("ProviderTransform.options - kimi family adaptive thinking", () => { expect(result.thinking).toBeUndefined() }) }) + +describe("ProviderTransform.message - sap-ai-core assistant prefill stripping", () => { + const createModel = (overrides: Record = {}) => + ({ + id: "sap-ai-core/anthropic--claude-4.6-sonnet", + providerID: "sap-ai-core", + api: { + id: "anthropic--claude-4.6-sonnet", + url: "https://api.ai.prod.eu-central-1.aws.ml.hana.ondemand.com", + npm: "@jerome-benoit/sap-ai-provider-v2", + }, + name: "Claude 4.6 Sonnet", + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + ...overrides, + }) as any + + const sapModel = createModel() + + test("strips trailing assistant message", () => { + const msgs = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi there!" }, + { role: "user", content: "How are you?" }, + { role: "assistant", content: "I am doing well." }, + ] as any[] + + const result = ProviderTransform.message(msgs, sapModel, {}) as any[] + + expect(result).toHaveLength(3) + expect(result[result.length - 1].role).toBe("user") + expect(result[result.length - 1].content).toBe("How are you?") + }) + + test("strips multiple trailing assistant messages", () => { + const msgs = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Step 1" }, + { role: "assistant", content: "Step 2" }, + ] as any[] + + const result = ProviderTransform.message(msgs, sapModel, {}) as any[] + + expect(result).toHaveLength(1) + expect(result[0].role).toBe("user") + expect(result[0].content).toBe("Hello") + }) + + test("leaves conversation ending in user message unchanged", () => { + const msgs = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi" }, + { role: "user", content: "Tell me a joke" }, + ] as any[] + + const result = ProviderTransform.message(msgs, sapModel, {}) as any[] + + expect(result).toHaveLength(3) + expect(result[2].role).toBe("user") + expect(result[2].content).toBe("Tell me a joke") + }) +}) + +