Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 37 additions & 4 deletions packages/core/src/plugin/provider/sap-ai-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>

export function sapAICoreFetch(upstream: FetchLike = fetch) {
return async (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
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) {
Expand All @@ -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(
Expand Down
42 changes: 40 additions & 2 deletions packages/core/test/plugin/provider-sap-ai-core.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -157,3 +157,41 @@ describe("SapAICorePlugin", () => {
),
)
})

type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>

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" })
})
})

29 changes: 28 additions & 1 deletion packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -590,7 +590,34 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {

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)
},
Expand Down
8 changes: 8 additions & 0 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
82 changes: 82 additions & 0 deletions packages/opencode/test/provider/sap-ai-core.test.ts
Original file line number Diff line number Diff line change
@@ -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 = <A, E, R>(values: Record<string, string | undefined>, effect: Effect.Effect<A, E, R>) =>
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: {} },
)
})
71 changes: 71 additions & 0 deletions packages/opencode/test/provider/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> = {}) =>
({
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")
})
})


Loading