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:
co-authored by
Aiden Cline
Aiden Cline
parent
e96c24ce2e
commit
ed4f833813
@@ -305,6 +305,7 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) {
|
|||||||
const route: AnyRoute = {
|
const route: AnyRoute = {
|
||||||
id: `ai-sdk:${ProviderV2.packageName(info.package) ?? "unknown"}`,
|
id: `ai-sdk:${ProviderV2.packageName(info.package) ?? "unknown"}`,
|
||||||
provider: ProviderID.make(info.providerID),
|
provider: ProviderID.make(info.providerID),
|
||||||
|
providerMetadataKey: optionKey,
|
||||||
protocol: "ai-sdk",
|
protocol: "ai-sdk",
|
||||||
endpoint: Endpoint.path("/", { baseURL: "https://ai-sdk.local" }),
|
endpoint: Endpoint.path("/", { baseURL: "https://ai-sdk.local" }),
|
||||||
auth: Auth.none,
|
auth: Auth.none,
|
||||||
@@ -417,7 +418,7 @@ function assistantPart(part: ContentPart): AssistantContent {
|
|||||||
case "media":
|
case "media":
|
||||||
return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }]
|
return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }]
|
||||||
case "reasoning":
|
case "reasoning":
|
||||||
return [{ type: "reasoning", text: part.text }]
|
return [{ type: "reasoning", text: part.text, providerOptions: providerOptions(part.providerMetadata) }]
|
||||||
case "tool-call":
|
case "tool-call":
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -426,6 +427,7 @@ function assistantPart(part: ContentPart): AssistantContent {
|
|||||||
toolName: part.name,
|
toolName: part.name,
|
||||||
input: part.input,
|
input: part.input,
|
||||||
providerExecuted: part.providerExecuted,
|
providerExecuted: part.providerExecuted,
|
||||||
|
providerOptions: providerOptions(part.providerMetadata),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
case "tool-result":
|
case "tool-result":
|
||||||
@@ -441,6 +443,7 @@ function toolResultPart(part: ContentPart): ToolResultContent[] {
|
|||||||
toolCallId: part.id,
|
toolCallId: part.id,
|
||||||
toolName: part.name,
|
toolName: part.name,
|
||||||
output: toolOutput(part.result),
|
output: toolOutput(part.result),
|
||||||
|
providerOptions: providerOptions(part.providerMetadata),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ const layer = Layer.effect(
|
|||||||
}
|
}
|
||||||
const resolved = yield* models.resolve(session)
|
const resolved = yield* models.resolve(session)
|
||||||
const model = resolved.model
|
const model = resolved.model
|
||||||
|
const providerMetadataKey = model.route.providerMetadataKey ?? model.provider
|
||||||
const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq)
|
const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq)
|
||||||
const context = entries.map((entry) => entry.message)
|
const context = entries.map((entry) => entry.message)
|
||||||
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
|
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
|
||||||
@@ -250,7 +251,7 @@ const layer = Layer.effect(
|
|||||||
.filter((part): part is string => part !== undefined && part.length > 0)
|
.filter((part): part is string => part !== undefined && part.length > 0)
|
||||||
.map(SystemPart.make),
|
.map(SystemPart.make),
|
||||||
messages: [
|
messages: [
|
||||||
...toLLMMessages(context, resolved.ref),
|
...toLLMMessages(context, resolved.ref, providerMetadataKey),
|
||||||
...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : []),
|
...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : []),
|
||||||
],
|
],
|
||||||
tools: toolMaterialization?.definitions ?? [],
|
tools: toolMaterialization?.definitions ?? [],
|
||||||
@@ -293,7 +294,7 @@ const layer = Layer.effect(
|
|||||||
// The selected catalog identity, not model.id: route-level ids are provider API
|
// The selected catalog identity, not model.id: route-level ids are provider API
|
||||||
// model ids (for example gpt-5.5-fast resolves to api id gpt-5.5).
|
// model ids (for example gpt-5.5-fast resolves to api id gpt-5.5).
|
||||||
model: resolved.ref,
|
model: resolved.ref,
|
||||||
provider: model.provider,
|
providerMetadataKey,
|
||||||
snapshot: startSnapshot,
|
snapshot: startSnapshot,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ type Input = {
|
|||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
readonly agent: AgentV2.ID
|
readonly agent: AgentV2.ID
|
||||||
readonly model: ModelV2.Ref
|
readonly model: ModelV2.Ref
|
||||||
readonly provider: string
|
readonly providerMetadataKey: string
|
||||||
readonly snapshot?: Snapshot.ID
|
readonly snapshot?: Snapshot.ID
|
||||||
readonly assistantMessageID?: SessionMessage.ID
|
readonly assistantMessageID?: SessionMessage.ID
|
||||||
}
|
}
|
||||||
@@ -87,7 +87,9 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||||||
assistantMessageID ??= SessionMessage.ID.create()
|
assistantMessageID ??= SessionMessage.ID.create()
|
||||||
stepStarted = true
|
stepStarted = true
|
||||||
yield* events.publish(SessionEvent.Step.Started, {
|
yield* events.publish(SessionEvent.Step.Started, {
|
||||||
...input,
|
sessionID: input.sessionID,
|
||||||
|
agent: input.agent,
|
||||||
|
model: input.model,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
snapshot: input.snapshot,
|
snapshot: input.snapshot,
|
||||||
})
|
})
|
||||||
@@ -97,8 +99,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||||||
assistantMessageID === undefined
|
assistantMessageID === undefined
|
||||||
? Effect.die(new Error("Tool event before assistant step start"))
|
? Effect.die(new Error("Tool event before assistant step start"))
|
||||||
: Effect.succeed(assistantMessageID)
|
: Effect.succeed(assistantMessageID)
|
||||||
const providerState = (metadata: ProviderMetadata | undefined) => metadata?.[input.provider]
|
const providerState = (metadata: ProviderMetadata | undefined) => metadata?.[input.providerMetadataKey]
|
||||||
|
|
||||||
const fragments = (
|
const fragments = (
|
||||||
name: string,
|
name: string,
|
||||||
ended: (id: string, value: string, ordinal: number, state?: Record<string, unknown>) => Effect.Effect<void>,
|
ended: (id: string, value: string, ordinal: number, state?: Record<string, unknown>) => Effect.Effect<void>,
|
||||||
@@ -352,14 +353,13 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||||||
if (tool.called) return yield* Effect.die(new Error(`Duplicate tool call: ${event.id}`))
|
if (tool.called) return yield* Effect.die(new Error(`Duplicate tool call: ${event.id}`))
|
||||||
tool.called = true
|
tool.called = true
|
||||||
tool.providerExecuted = event.providerExecuted === true
|
tool.providerExecuted = event.providerExecuted === true
|
||||||
const state = providerState(event.providerMetadata)
|
|
||||||
yield* events.publish(SessionEvent.Tool.Called, {
|
yield* events.publish(SessionEvent.Tool.Called, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
assistantMessageID: tool.assistantMessageID,
|
assistantMessageID: tool.assistantMessageID,
|
||||||
callID: event.id,
|
callID: event.id,
|
||||||
input: record(event.input),
|
input: record(event.input),
|
||||||
executed: tool.providerExecuted,
|
executed: tool.providerExecuted,
|
||||||
state,
|
state: providerState(event.providerMetadata),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => {
|
const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, providerMetadataKey: string) => {
|
||||||
const sameModel =
|
const sameModel =
|
||||||
String(message.model.providerID) === String(model.providerID) && String(message.model.id) === String(model.id)
|
String(message.model.providerID) === String(model.providerID) && String(message.model.id) === String(model.id)
|
||||||
const reuseProviderMetadata = sameModel && message.error === undefined
|
const reuseProviderMetadata = sameModel && message.error === undefined
|
||||||
@@ -125,7 +125,7 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => {
|
|||||||
{
|
{
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
text: item.text,
|
text: item.text,
|
||||||
providerMetadata: providerMetadata(model.providerID, item.state),
|
providerMetadata: providerMetadata(providerMetadataKey, item.state),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: item.text.length > 0
|
: item.text.length > 0
|
||||||
@@ -133,13 +133,13 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => {
|
|||||||
: []
|
: []
|
||||||
const call = toolCall(
|
const call = toolCall(
|
||||||
item,
|
item,
|
||||||
reuseProviderMetadata ? providerMetadata(model.providerID, item.providerState) : undefined,
|
reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.providerState) : undefined,
|
||||||
)
|
)
|
||||||
if (item.executed !== true) return [call]
|
if (item.executed !== true) return [call]
|
||||||
const result = toolResult(
|
const result = toolResult(
|
||||||
item,
|
item,
|
||||||
reuseProviderMetadata
|
reuseProviderMetadata
|
||||||
? providerMetadata(model.providerID, item.providerResultState ?? item.providerState)
|
? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState)
|
||||||
: undefined,
|
: undefined,
|
||||||
)
|
)
|
||||||
return result ? [call, result] : [call]
|
return result ? [call, result] : [call]
|
||||||
@@ -155,7 +155,7 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => {
|
|||||||
toolResult(
|
toolResult(
|
||||||
item,
|
item,
|
||||||
reuseProviderMetadata
|
reuseProviderMetadata
|
||||||
? providerMetadata(model.providerID, item.providerResultState ?? item.providerState)
|
? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState)
|
||||||
: undefined,
|
: undefined,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -168,7 +168,7 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
function toLLMMessage(message: SessionMessage.Info, model: ModelV2.Ref): Message[] {
|
function toLLMMessage(message: SessionMessage.Info, model: ModelV2.Ref, providerMetadataKey: string): Message[] {
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case "agent-switched":
|
case "agent-switched":
|
||||||
case "model-switched":
|
case "model-switched":
|
||||||
@@ -207,7 +207,7 @@ function toLLMMessage(message: SessionMessage.Info, model: ModelV2.Ref): Message
|
|||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
case "assistant":
|
case "assistant":
|
||||||
return assistant(message, model)
|
return assistant(message, model, providerMetadataKey)
|
||||||
case "compaction":
|
case "compaction":
|
||||||
if (message.status !== "completed") return []
|
if (message.status !== "completed") return []
|
||||||
return [
|
return [
|
||||||
@@ -232,5 +232,8 @@ ${message.recent}
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Translate projected V2 Session history into canonical @opencode-ai/llm context. */
|
/** Translate projected V2 Session history into canonical @opencode-ai/llm context. */
|
||||||
export const toLLMMessages = (messages: readonly SessionMessage.Info[], model: ModelV2.Ref) =>
|
export const toLLMMessages = (
|
||||||
messages.flatMap((message) => toLLMMessage(message, model))
|
messages: readonly SessionMessage.Info[],
|
||||||
|
model: ModelV2.Ref,
|
||||||
|
providerMetadataKey: string = model.providerID,
|
||||||
|
) => messages.flatMap((message) => toLLMMessage(message, model, providerMetadataKey))
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { LanguageModelV3CallOptions } from "@ai-sdk/provider"
|
|||||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
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 { LLMClient } from "@opencode-ai/llm/route"
|
||||||
import { expect } from "bun:test"
|
import { expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
@@ -67,3 +67,54 @@ it.effect("projects request settings, headers, and body overlays", () =>
|
|||||||
expect(body).toEqual({ safety_setting: "strict" })
|
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", () => {
|
test("lowers failed assistant reasoning to text", () => {
|
||||||
const messages = toLLMMessages(
|
const messages = toLLMMessages(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ describe("SessionRunnerModel", () => {
|
|||||||
expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" })
|
expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" })
|
||||||
expect(resolved.route).toMatchObject({
|
expect(resolved.route).toMatchObject({
|
||||||
id: "openai-responses",
|
id: "openai-responses",
|
||||||
|
providerMetadataKey: "openai",
|
||||||
endpoint: { baseURL: "https://openai.example/v1" },
|
endpoint: { baseURL: "https://openai.example/v1" },
|
||||||
defaults: {
|
defaults: {
|
||||||
headers: { "x-test": "header" },
|
headers: { "x-test": "header" },
|
||||||
@@ -264,6 +265,7 @@ describe("SessionRunnerModel", () => {
|
|||||||
|
|
||||||
expect(resolved.route).toMatchObject({
|
expect(resolved.route).toMatchObject({
|
||||||
id: "anthropic-messages",
|
id: "anthropic-messages",
|
||||||
|
providerMetadataKey: "anthropic",
|
||||||
endpoint: { baseURL: "https://anthropic.example/v1" },
|
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 sessionID = SessionV2.ID.make("ses_tool_event_test")
|
||||||
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
|
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
|
||||||
|
|
||||||
const capture = () => {
|
const capture = (providerMetadataKey = "anthropic") => {
|
||||||
const published: Array<{ readonly type: string; readonly data: unknown }> = []
|
const published: Array<{ readonly type: string; readonly data: unknown }> = []
|
||||||
const events = EventV2.Service.of({
|
const events = EventV2.Service.of({
|
||||||
publish: (definition, data) =>
|
publish: (definition, data) =>
|
||||||
@@ -45,9 +45,9 @@ const capture = () => {
|
|||||||
agent: AgentV2.ID.make("build"),
|
agent: AgentV2.ID.make("build"),
|
||||||
model: {
|
model: {
|
||||||
id: ModelV2.ID.make("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")
|
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()
|
const { published, publisher } = capture()
|
||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
publisher.publish(
|
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({
|
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()
|
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(
|
await Effect.runPromise(
|
||||||
publisher.publish(
|
publisher.publish(
|
||||||
LLMEvent.reasoningDelta({
|
LLMEvent.reasoningDelta({
|
||||||
id: "reasoning",
|
id: "reasoning",
|
||||||
text: "",
|
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({
|
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",
|
name: "web_search",
|
||||||
input: { query: "hello" },
|
input: { query: "hello" },
|
||||||
providerExecuted: true,
|
providerExecuted: true,
|
||||||
providerMetadata: { fake: { source: "provider" } },
|
providerMetadata: { openai: { source: "provider" } },
|
||||||
}),
|
}),
|
||||||
LLMEvent.toolResult({
|
LLMEvent.toolResult({
|
||||||
id: "call-provider",
|
id: "call-provider",
|
||||||
@@ -1687,7 +1687,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
providerExecuted: true,
|
providerExecuted: true,
|
||||||
providerMetadata: { fake: { source: "provider" } },
|
providerMetadata: { openai: { source: "provider" } },
|
||||||
}),
|
}),
|
||||||
LLMEvent.stepFinish({
|
LLMEvent.stepFinish({
|
||||||
index: 0,
|
index: 0,
|
||||||
@@ -1834,21 +1834,21 @@ describe("SessionRunnerLLM", () => {
|
|||||||
LLMEvent.reasoningDelta({ id: "reasoning-anthropic", text: "Signed thought" }),
|
LLMEvent.reasoningDelta({ id: "reasoning-anthropic", text: "Signed thought" }),
|
||||||
LLMEvent.reasoningEnd({
|
LLMEvent.reasoningEnd({
|
||||||
id: "reasoning-anthropic",
|
id: "reasoning-anthropic",
|
||||||
providerMetadata: { fake: { signature: "sig_1" }, anthropic: { ignored: true } },
|
providerMetadata: { openai: { signature: "sig_1" }, anthropic: { ignored: true } },
|
||||||
}),
|
}),
|
||||||
LLMEvent.reasoningStart({
|
LLMEvent.reasoningStart({
|
||||||
id: "reasoning-openai",
|
id: "reasoning-openai",
|
||||||
providerMetadata: {
|
providerMetadata: {
|
||||||
fake: { itemId: "rs_1", reasoningEncryptedContent: null },
|
openai: { itemId: "rs_1", reasoningEncryptedContent: null },
|
||||||
openai: { ignored: true },
|
anthropic: { ignored: true },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
LLMEvent.reasoningDelta({ id: "reasoning-openai", text: "Encrypted thought" }),
|
LLMEvent.reasoningDelta({ id: "reasoning-openai", text: "Encrypted thought" }),
|
||||||
LLMEvent.reasoningEnd({
|
LLMEvent.reasoningEnd({
|
||||||
id: "reasoning-openai",
|
id: "reasoning-openai",
|
||||||
providerMetadata: {
|
providerMetadata: {
|
||||||
fake: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
|
openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
|
||||||
openai: { ignored: true },
|
anthropic: { ignored: true },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
|
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
|
||||||
@@ -1862,7 +1862,11 @@ describe("SessionRunnerLLM", () => {
|
|||||||
{
|
{
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
content: [
|
content: [
|
||||||
{ type: "reasoning", text: "Signed thought", state: { signature: "sig_1" } },
|
{
|
||||||
|
type: "reasoning",
|
||||||
|
text: "Signed thought",
|
||||||
|
state: { signature: "sig_1" },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
text: "Encrypted thought",
|
text: "Encrypted thought",
|
||||||
@@ -1877,11 +1881,15 @@ describe("SessionRunnerLLM", () => {
|
|||||||
yield* session.resume(sessionID)
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
expect(requests[1]?.messages[1]?.content).toEqual([
|
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",
|
type: "reasoning",
|
||||||
text: "Encrypted thought",
|
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",
|
name: "web_search",
|
||||||
input: { query: "Effect" },
|
input: { query: "Effect" },
|
||||||
providerExecuted: true,
|
providerExecuted: true,
|
||||||
providerMetadata: { fake: { itemId: "hosted-search" }, openai: { ignored: true } },
|
providerMetadata: { openai: { itemId: "hosted-search" }, fake: { ignored: true } },
|
||||||
}),
|
}),
|
||||||
LLMEvent.toolResult({
|
LLMEvent.toolResult({
|
||||||
id: "hosted-search",
|
id: "hosted-search",
|
||||||
name: "web_search",
|
name: "web_search",
|
||||||
result: { type: "json", value: [{ title: "Effect" }] },
|
result: { type: "json", value: [{ title: "Effect" }] },
|
||||||
providerExecuted: true,
|
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.stepFinish({ index: 0, reason: "stop" }),
|
||||||
LLMEvent.finish({ reason: "stop" }),
|
LLMEvent.finish({ reason: "stop" }),
|
||||||
@@ -1926,7 +1934,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
name: "web_search",
|
name: "web_search",
|
||||||
input: { query: "Effect" },
|
input: { query: "Effect" },
|
||||||
providerExecuted: true,
|
providerExecuted: true,
|
||||||
providerMetadata: { fake: { itemId: "hosted-search" } },
|
providerMetadata: { openai: { itemId: "hosted-search" } },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "tool-result",
|
type: "tool-result",
|
||||||
@@ -1934,7 +1942,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
name: "web_search",
|
name: "web_search",
|
||||||
result: { type: "json", value: [{ title: "Effect" }] },
|
result: { type: "json", value: [{ title: "Effect" }] },
|
||||||
providerExecuted: true,
|
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",
|
type: "tool-call",
|
||||||
id: "call-hosted-interrupted",
|
id: "call-hosted-interrupted",
|
||||||
providerExecuted: true,
|
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" } },
|
{ type: "tool-result", id: "call-hosted-interrupted", providerExecuted: true, result: { type: "error" } },
|
||||||
])
|
])
|
||||||
@@ -2670,7 +2678,10 @@ describe("SessionRunnerLLM", () => {
|
|||||||
{
|
{
|
||||||
type: "tool",
|
type: "tool",
|
||||||
id: "call-missing",
|
id: "call-missing",
|
||||||
state: { status: "error", error: { message: "Unknown tool: missing" } },
|
state: {
|
||||||
|
status: "error",
|
||||||
|
error: { type: "tool.unknown", message: "Unknown tool: missing" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -876,6 +876,7 @@ export const protocol = Protocol.make({
|
|||||||
export const route = Route.make({
|
export const route = Route.make({
|
||||||
id: ADAPTER,
|
id: ADAPTER,
|
||||||
provider: "anthropic",
|
provider: "anthropic",
|
||||||
|
providerMetadataKey: "anthropic",
|
||||||
protocol,
|
protocol,
|
||||||
endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }),
|
endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }),
|
||||||
auth: Auth.none,
|
auth: Auth.none,
|
||||||
|
|||||||
@@ -657,6 +657,7 @@ export const protocol = Protocol.make({
|
|||||||
export const route = Route.make({
|
export const route = Route.make({
|
||||||
id: ADAPTER,
|
id: ADAPTER,
|
||||||
provider: "bedrock",
|
provider: "bedrock",
|
||||||
|
providerMetadataKey: "bedrock",
|
||||||
protocol,
|
protocol,
|
||||||
// Bedrock's URL embeds the region in the route endpoint host and the
|
// Bedrock's URL embeds the region in the route endpoint host and the
|
||||||
// validated modelId in the path. We read the validated body so the URL
|
// validated modelId in the path. We read the validated body so the URL
|
||||||
|
|||||||
@@ -500,6 +500,7 @@ export const protocol = Protocol.make({
|
|||||||
export const route = Route.make({
|
export const route = Route.make({
|
||||||
id: ADAPTER,
|
id: ADAPTER,
|
||||||
provider: "google",
|
provider: "google",
|
||||||
|
providerMetadataKey: "google",
|
||||||
protocol,
|
protocol,
|
||||||
// Gemini's path embeds the model id and pins SSE framing at the URL level.
|
// Gemini's path embeds the model id and pins SSE framing at the URL level.
|
||||||
endpoint: Endpoint.path(({ request }) => `/models/${request.model.id}:streamGenerateContent?alt=sse`, {
|
endpoint: Endpoint.path(({ request }) => `/models/${request.model.id}:streamGenerateContent?alt=sse`, {
|
||||||
|
|||||||
@@ -493,6 +493,7 @@ export const httpTransport = HttpTransport.sseJson.with<OpenAIChatBody>()
|
|||||||
export const route = Route.make({
|
export const route = Route.make({
|
||||||
id: ADAPTER,
|
id: ADAPTER,
|
||||||
provider: "openai",
|
provider: "openai",
|
||||||
|
providerMetadataKey: "openai",
|
||||||
protocol,
|
protocol,
|
||||||
endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }),
|
endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }),
|
||||||
auth: Auth.none,
|
auth: Auth.none,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export type OpenAICompatibleChatModelInput = RouteRoutedModelInput
|
|||||||
*/
|
*/
|
||||||
export const route = Route.make({
|
export const route = Route.make({
|
||||||
id: ADAPTER,
|
id: ADAPTER,
|
||||||
|
providerMetadataKey: "openai",
|
||||||
protocol: OpenAIChat.protocol,
|
protocol: OpenAIChat.protocol,
|
||||||
endpoint: Endpoint.path("/chat/completions"),
|
endpoint: Endpoint.path("/chat/completions"),
|
||||||
framing: Framing.sse,
|
framing: Framing.sse,
|
||||||
|
|||||||
@@ -990,6 +990,7 @@ export const httpTransport = HttpTransport.sseJson.with<OpenAIResponsesBody>()
|
|||||||
export const route = Route.make({
|
export const route = Route.make({
|
||||||
id: ADAPTER,
|
id: ADAPTER,
|
||||||
provider: "openai",
|
provider: "openai",
|
||||||
|
providerMetadataKey: "openai",
|
||||||
protocol,
|
protocol,
|
||||||
endpoint,
|
endpoint,
|
||||||
auth,
|
auth,
|
||||||
@@ -1018,6 +1019,7 @@ export const webSocketTransport = WebSocketTransport.jsonTransport.with<
|
|||||||
export const webSocketRoute = Route.make({
|
export const webSocketRoute = Route.make({
|
||||||
id: `${ADAPTER}-websocket`,
|
id: `${ADAPTER}-websocket`,
|
||||||
provider: "openai",
|
provider: "openai",
|
||||||
|
providerMetadataKey: "openai",
|
||||||
protocol,
|
protocol,
|
||||||
endpoint,
|
endpoint,
|
||||||
auth,
|
auth,
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export interface RouteBody<Body> {
|
|||||||
export interface Route<Body, Prepared = unknown> {
|
export interface Route<Body, Prepared = unknown> {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly provider?: ProviderID
|
readonly provider?: ProviderID
|
||||||
|
/** ProviderMetadata namespace emitted and consumed by this route. */
|
||||||
|
readonly providerMetadataKey?: string
|
||||||
readonly protocol: ProtocolID
|
readonly protocol: ProtocolID
|
||||||
readonly endpoint: Endpoint<Body>
|
readonly endpoint: Endpoint<Body>
|
||||||
readonly auth: AuthDef
|
readonly auth: AuthDef
|
||||||
@@ -184,6 +186,8 @@ export interface MakeInput<Body, Frame, Event, State> {
|
|||||||
readonly id: string
|
readonly id: string
|
||||||
/** Provider identity for route-owned model construction. */
|
/** Provider identity for route-owned model construction. */
|
||||||
readonly provider?: string | ProviderID
|
readonly provider?: string | ProviderID
|
||||||
|
/** ProviderMetadata namespace emitted and consumed by this route. */
|
||||||
|
readonly providerMetadataKey?: string
|
||||||
/** Semantic API contract — owns body construction, body schema, and parsing. */
|
/** Semantic API contract — owns body construction, body schema, and parsing. */
|
||||||
readonly protocol: Protocol<Body, Frame, Event, State>
|
readonly protocol: Protocol<Body, Frame, Event, State>
|
||||||
/** Where the request is sent. */
|
/** Where the request is sent. */
|
||||||
@@ -203,6 +207,8 @@ export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
|
|||||||
readonly id: string
|
readonly id: string
|
||||||
/** Provider identity for route-owned model construction. */
|
/** Provider identity for route-owned model construction. */
|
||||||
readonly provider?: string | ProviderID
|
readonly provider?: string | ProviderID
|
||||||
|
/** ProviderMetadata namespace emitted and consumed by this route. */
|
||||||
|
readonly providerMetadataKey?: string
|
||||||
/** Semantic API contract — owns body construction, body schema, and parsing. */
|
/** Semantic API contract — owns body construction, body schema, and parsing. */
|
||||||
readonly protocol: Protocol<Body, Frame, Event, State>
|
readonly protocol: Protocol<Body, Frame, Event, State>
|
||||||
/** Where the request is sent. */
|
/** Where the request is sent. */
|
||||||
@@ -248,6 +254,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
|||||||
const route: Route<Body, Prepared> = {
|
const route: Route<Body, Prepared> = {
|
||||||
id: routeInput.id,
|
id: routeInput.id,
|
||||||
provider: routeInput.provider === undefined ? undefined : ProviderID.make(routeInput.provider),
|
provider: routeInput.provider === undefined ? undefined : ProviderID.make(routeInput.provider),
|
||||||
|
providerMetadataKey: routeInput.providerMetadataKey,
|
||||||
protocol: protocol.id,
|
protocol: protocol.id,
|
||||||
endpoint: routeInput.endpoint,
|
endpoint: routeInput.endpoint,
|
||||||
auth: routeInput.auth ?? Auth.none,
|
auth: routeInput.auth ?? Auth.none,
|
||||||
@@ -329,6 +336,7 @@ export function make<Body, Prepared, Frame, Event, State>(
|
|||||||
return makeFromTransport({
|
return makeFromTransport({
|
||||||
id: input.id,
|
id: input.id,
|
||||||
provider: input.provider,
|
provider: input.provider,
|
||||||
|
providerMetadataKey: input.providerMetadataKey,
|
||||||
protocol,
|
protocol,
|
||||||
endpoint: input.endpoint,
|
endpoint: input.endpoint,
|
||||||
auth: input.auth,
|
auth: input.auth,
|
||||||
|
|||||||
Reference in New Issue
Block a user