fix(core): preserve provider metadata namespaces (#35817)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
This commit is contained in:
parent
e96c24ce2e
commit
ed4f833813
16 changed files with 201 additions and 46 deletions
|
|
@ -2,7 +2,7 @@ import type { LanguageModelV3CallOptions } from "@ai-sdk/provider"
|
|||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { LLM } from "@opencode-ai/llm"
|
||||
import { LLM, Message } from "@opencode-ai/llm"
|
||||
import { LLMClient } from "@opencode-ai/llm/route"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
|
|
@ -67,3 +67,54 @@ it.effect("projects request settings, headers, and body overlays", () =>
|
|||
expect(body).toEqual({ safety_setting: "strict" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects replay metadata onto AI SDK prompt parts", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
|
||||
})
|
||||
|
||||
const resolved = yield* aisdk.model(model("@ai-sdk/anthropic"))
|
||||
expect(resolved.route.providerMetadataKey).toBe("anthropic")
|
||||
const prepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
|
||||
LLM.request({
|
||||
model: resolved,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{ type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "signed" } } },
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "hosted",
|
||||
name: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { anthropic: { blockType: "server_tool_use" } },
|
||||
},
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.prompt).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Think",
|
||||
providerOptions: { anthropic: { signature: "signed" } },
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "hosted",
|
||||
toolName: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
providerOptions: { anthropic: { blockType: "server_tool_use" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -454,6 +454,34 @@ Recent work
|
|||
])
|
||||
})
|
||||
|
||||
test("replays flat state under an OpenCode hosted model's route key", () => {
|
||||
const opencode = ModelV2.Ref.make({ id: ModelV2.ID.make("claude-fable-5"), providerID: ProviderV2.ID.opencode })
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.Assistant.make({
|
||||
id: id("assistant-opencode-reasoning"),
|
||||
type: "assistant",
|
||||
agent: build,
|
||||
model: opencode,
|
||||
content: [
|
||||
SessionMessage.AssistantReasoning.make({
|
||||
type: "reasoning",
|
||||
text: "Think",
|
||||
state: { signature: "signed" },
|
||||
}),
|
||||
],
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
opencode,
|
||||
"anthropic",
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "signed" } } },
|
||||
])
|
||||
})
|
||||
|
||||
test("lowers failed assistant reasoning to text", () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ describe("SessionRunnerModel", () => {
|
|||
expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" })
|
||||
expect(resolved.route).toMatchObject({
|
||||
id: "openai-responses",
|
||||
providerMetadataKey: "openai",
|
||||
endpoint: { baseURL: "https://openai.example/v1" },
|
||||
defaults: {
|
||||
headers: { "x-test": "header" },
|
||||
|
|
@ -264,6 +265,7 @@ describe("SessionRunnerModel", () => {
|
|||
|
||||
expect(resolved.route).toMatchObject({
|
||||
id: "anthropic-messages",
|
||||
providerMetadataKey: "anthropic",
|
||||
endpoint: { baseURL: "https://anthropic.example/v1" },
|
||||
})
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publis
|
|||
const sessionID = SessionV2.ID.make("ses_tool_event_test")
|
||||
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
|
||||
|
||||
const capture = () => {
|
||||
const capture = (providerMetadataKey = "anthropic") => {
|
||||
const published: Array<{ readonly type: string; readonly data: unknown }> = []
|
||||
const events = EventV2.Service.of({
|
||||
publish: (definition, data) =>
|
||||
|
|
@ -45,9 +45,9 @@ const capture = () => {
|
|||
agent: AgentV2.ID.make("build"),
|
||||
model: {
|
||||
id: ModelV2.ID.make("model"),
|
||||
providerID: ProviderV2.ID.make("provider"),
|
||||
providerID: ProviderV2.ID.opencode,
|
||||
},
|
||||
provider: "openai",
|
||||
providerMetadataKey,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
|
@ -99,35 +99,76 @@ test("provider-executed success retains its raw provider result", async () => {
|
|||
expect(success?.data).toHaveProperty("result")
|
||||
})
|
||||
|
||||
test("provider state uses the route provider instead of the catalog provider", async () => {
|
||||
test("provider metadata is flattened using the route key", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(
|
||||
publisher.publish(
|
||||
LLMEvent.reasoningStart({ id: "reasoning", providerMetadata: { openai: { itemId: "reasoning" } } }),
|
||||
LLMEvent.reasoningStart({ id: "reasoning", providerMetadata: { anthropic: { signature: "signed" } } }),
|
||||
),
|
||||
)
|
||||
|
||||
expect(published.find((event) => event.type === "session.reasoning.started.1")?.data).toMatchObject({
|
||||
state: { itemId: "reasoning" },
|
||||
state: { signature: "signed" },
|
||||
})
|
||||
})
|
||||
|
||||
test("reasoning state from an empty delta is retained at reasoning end", async () => {
|
||||
test("reasoning state from start, empty delta, and end is merged", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.reasoningStart({ id: "reasoning" })))
|
||||
await Effect.runPromise(
|
||||
publisher.publish(
|
||||
LLMEvent.reasoningStart({ id: "reasoning", providerMetadata: { anthropic: { blockType: "thinking" } } }),
|
||||
),
|
||||
)
|
||||
await Effect.runPromise(
|
||||
publisher.publish(
|
||||
LLMEvent.reasoningDelta({
|
||||
id: "reasoning",
|
||||
text: "",
|
||||
providerMetadata: { openai: { signature: "signed" } },
|
||||
providerMetadata: { anthropic: { signature: "signed" }, gateway: { traceID: "trace" } },
|
||||
}),
|
||||
),
|
||||
)
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.reasoningEnd({ id: "reasoning" })))
|
||||
await Effect.runPromise(
|
||||
publisher.publish(
|
||||
LLMEvent.reasoningEnd({ id: "reasoning", providerMetadata: { anthropic: { stopReason: "tool_use" } } }),
|
||||
),
|
||||
)
|
||||
|
||||
expect(published.find((event) => event.type === "session.reasoning.ended.1")?.data).toMatchObject({
|
||||
state: { signature: "signed" },
|
||||
state: { blockType: "thinking", signature: "signed", stopReason: "tool_use" },
|
||||
})
|
||||
})
|
||||
|
||||
test("provider-executed tool metadata is flattened using the route key", async () => {
|
||||
const { published, publisher } = capture("openai")
|
||||
await Effect.runPromise(
|
||||
publisher.publish(
|
||||
LLMEvent.toolCall({
|
||||
id: "hosted",
|
||||
name: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "call" } },
|
||||
}),
|
||||
),
|
||||
)
|
||||
await Effect.runPromise(
|
||||
publisher.publish(
|
||||
LLMEvent.toolResult({
|
||||
id: "hosted",
|
||||
name: "web_search",
|
||||
result: { type: "json", value: { found: true } },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "result" } },
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(published.find((event) => event.type === "session.tool.called.1")?.data).toMatchObject({
|
||||
state: { itemId: "call" },
|
||||
})
|
||||
expect(published.find((event) => event.type === "session.tool.success.1")?.data).toMatchObject({
|
||||
resultState: { itemId: "result" },
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1674,7 +1674,7 @@ describe("SessionRunnerLLM", () => {
|
|||
name: "web_search",
|
||||
input: { query: "hello" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { source: "provider" } },
|
||||
providerMetadata: { openai: { source: "provider" } },
|
||||
}),
|
||||
LLMEvent.toolResult({
|
||||
id: "call-provider",
|
||||
|
|
@ -1687,7 +1687,7 @@ describe("SessionRunnerLLM", () => {
|
|||
],
|
||||
},
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { source: "provider" } },
|
||||
providerMetadata: { openai: { source: "provider" } },
|
||||
}),
|
||||
LLMEvent.stepFinish({
|
||||
index: 0,
|
||||
|
|
@ -1834,21 +1834,21 @@ describe("SessionRunnerLLM", () => {
|
|||
LLMEvent.reasoningDelta({ id: "reasoning-anthropic", text: "Signed thought" }),
|
||||
LLMEvent.reasoningEnd({
|
||||
id: "reasoning-anthropic",
|
||||
providerMetadata: { fake: { signature: "sig_1" }, anthropic: { ignored: true } },
|
||||
providerMetadata: { openai: { signature: "sig_1" }, anthropic: { ignored: true } },
|
||||
}),
|
||||
LLMEvent.reasoningStart({
|
||||
id: "reasoning-openai",
|
||||
providerMetadata: {
|
||||
fake: { itemId: "rs_1", reasoningEncryptedContent: null },
|
||||
openai: { ignored: true },
|
||||
openai: { itemId: "rs_1", reasoningEncryptedContent: null },
|
||||
anthropic: { ignored: true },
|
||||
},
|
||||
}),
|
||||
LLMEvent.reasoningDelta({ id: "reasoning-openai", text: "Encrypted thought" }),
|
||||
LLMEvent.reasoningEnd({
|
||||
id: "reasoning-openai",
|
||||
providerMetadata: {
|
||||
fake: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
|
||||
openai: { ignored: true },
|
||||
openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
|
||||
anthropic: { ignored: true },
|
||||
},
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
|
||||
|
|
@ -1862,7 +1862,11 @@ describe("SessionRunnerLLM", () => {
|
|||
{
|
||||
type: "assistant",
|
||||
content: [
|
||||
{ type: "reasoning", text: "Signed thought", state: { signature: "sig_1" } },
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Signed thought",
|
||||
state: { signature: "sig_1" },
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Encrypted thought",
|
||||
|
|
@ -1877,11 +1881,15 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests[1]?.messages[1]?.content).toEqual([
|
||||
{ type: "reasoning", text: "Signed thought", providerMetadata: { fake: { signature: "sig_1" } } },
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Signed thought",
|
||||
providerMetadata: { openai: { signature: "sig_1" } },
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Encrypted thought",
|
||||
providerMetadata: { fake: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
|
|
@ -1899,14 +1907,14 @@ describe("SessionRunnerLLM", () => {
|
|||
name: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { itemId: "hosted-search" }, openai: { ignored: true } },
|
||||
providerMetadata: { openai: { itemId: "hosted-search" }, fake: { ignored: true } },
|
||||
}),
|
||||
LLMEvent.toolResult({
|
||||
id: "hosted-search",
|
||||
name: "web_search",
|
||||
result: { type: "json", value: [{ title: "Effect" }] },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { blockType: "web_search_tool_result" }, anthropic: { ignored: true } },
|
||||
providerMetadata: { openai: { blockType: "web_search_tool_result" }, anthropic: { ignored: true } },
|
||||
}),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
|
|
@ -1926,7 +1934,7 @@ describe("SessionRunnerLLM", () => {
|
|||
name: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { itemId: "hosted-search" } },
|
||||
providerMetadata: { openai: { itemId: "hosted-search" } },
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
|
|
@ -1934,7 +1942,7 @@ describe("SessionRunnerLLM", () => {
|
|||
name: "web_search",
|
||||
result: { type: "json", value: [{ title: "Effect" }] },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { blockType: "web_search_tool_result" } },
|
||||
providerMetadata: { openai: { blockType: "web_search_tool_result" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
|
|
@ -2469,7 +2477,7 @@ describe("SessionRunnerLLM", () => {
|
|||
type: "tool-call",
|
||||
id: "call-hosted-interrupted",
|
||||
providerExecuted: true,
|
||||
providerMetadata: { fake: { itemId: "call-hosted-interrupted" } },
|
||||
providerMetadata: { openai: { itemId: "call-hosted-interrupted" } },
|
||||
},
|
||||
{ type: "tool-result", id: "call-hosted-interrupted", providerExecuted: true, result: { type: "error" } },
|
||||
])
|
||||
|
|
@ -2670,7 +2678,10 @@ describe("SessionRunnerLLM", () => {
|
|||
{
|
||||
type: "tool",
|
||||
id: "call-missing",
|
||||
state: { status: "error", error: { message: "Unknown tool: missing" } },
|
||||
state: {
|
||||
status: "error",
|
||||
error: { type: "tool.unknown", message: "Unknown tool: missing" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue