Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49fa822aa6 | ||
|
|
c933504d9c | ||
|
|
2d0d3d596e | ||
|
|
3bd98ea055 | ||
|
|
7e997cfba4 | ||
|
|
5d6f2a1524 | ||
|
|
b1cb71856e | ||
|
|
518264fcd9 | ||
|
|
7235c9c9b8 | ||
|
|
274033cd52 | ||
|
|
9b369ee815 | ||
|
|
721ff5121e | ||
|
|
cfbf5d1c6f | ||
|
|
02cb7e7b71 | ||
|
|
942630eb4a | ||
|
|
ce66b191d1 | ||
|
|
6f1f5944ce | ||
|
|
1c49b2ed67 | ||
|
|
3c6be604ec | ||
|
|
ddc02c2893 | ||
|
|
2703eff2e2 | ||
|
|
38e4540119 | ||
|
|
4100fcbd17 | ||
|
|
83cb0f60ec | ||
|
|
effd96755e | ||
|
|
5801cce1b5 | ||
|
|
77e6c0d329 | ||
|
|
fed716ada5 | ||
|
|
426d92e352 | ||
|
|
d0412a213b | ||
|
|
16aa67086b | ||
|
|
7cea32ee08 | ||
|
|
128d10d9e9 | ||
|
|
5ef72e1101 | ||
|
|
d8060dc9ad | ||
|
|
f71fb18d3d | ||
|
|
5654dd2aad | ||
|
|
64dde0cb15 | ||
|
|
a658e43eeb | ||
|
|
ca8a42c973 | ||
|
|
8f5f75db12 | ||
|
|
4ff0d07b1d | ||
|
|
2a571b3cee | ||
|
|
0fffcdfe46 | ||
|
|
2ba2ee52e8 | ||
|
|
56d818fc34 | ||
|
|
9c8da69196 | ||
|
|
a78018697c | ||
|
|
e45b6ef1de | ||
|
|
b616543ac2 | ||
|
|
2bd3d9a696 | ||
|
|
fa15dbc5ec | ||
|
|
312e5c7a7c | ||
|
|
049502fac6 | ||
|
|
cc2915be16 | ||
|
|
ce061bf661 | ||
|
|
3b8790e034 | ||
|
|
a4f3cedcdf |
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -85,7 +85,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -120,7 +120,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -147,7 +147,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -171,7 +171,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -195,7 +195,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.48",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -229,7 +229,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -283,7 +283,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -313,7 +313,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -329,7 +329,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -342,7 +342,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
@@ -360,7 +360,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.48",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -496,7 +496,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -534,7 +534,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -549,7 +549,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -584,7 +584,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -633,7 +633,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.48",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -231,6 +231,7 @@ export function createChildStoreManager(input: {
|
||||
limit: 5,
|
||||
message: {},
|
||||
part: {},
|
||||
part_text_accum_delta: {},
|
||||
})
|
||||
children[key] = child
|
||||
disposers.set(key, dispose)
|
||||
|
||||
@@ -81,6 +81,7 @@ const baseState = (input: Partial<State> = {}) =>
|
||||
limit: 10,
|
||||
message: {},
|
||||
part: {},
|
||||
part_text_accum_delta: {},
|
||||
...input,
|
||||
}) as State
|
||||
|
||||
|
||||
@@ -211,6 +211,12 @@ export function applyDirectoryEvent(input: {
|
||||
const result = Binary.search(messages, props.messageID, (m) => m.id)
|
||||
if (result.found) messages.splice(result.index, 1)
|
||||
}
|
||||
const parts = draft.part[props.messageID]
|
||||
if (parts) {
|
||||
for (const part of parts) {
|
||||
delete draft.part_text_accum_delta[part.id]
|
||||
}
|
||||
}
|
||||
delete draft.part[props.messageID]
|
||||
}),
|
||||
)
|
||||
@@ -219,6 +225,11 @@ export function applyDirectoryEvent(input: {
|
||||
case "message.part.updated": {
|
||||
const part = (event.properties as { part: Part }).part
|
||||
if (SKIP_PARTS.has(part.type)) break
|
||||
input.setStore(
|
||||
produce((draft) => {
|
||||
delete draft.part_text_accum_delta[part.id]
|
||||
}),
|
||||
)
|
||||
const parts = input.store.part[part.messageID]
|
||||
if (!parts) {
|
||||
input.setStore("part", part.messageID, [part])
|
||||
@@ -240,6 +251,11 @@ export function applyDirectoryEvent(input: {
|
||||
}
|
||||
case "message.part.removed": {
|
||||
const props = event.properties as { messageID: string; partID: string }
|
||||
input.setStore(
|
||||
produce((draft) => {
|
||||
delete draft.part_text_accum_delta[props.partID]
|
||||
}),
|
||||
)
|
||||
const parts = input.store.part[props.messageID]
|
||||
if (!parts) break
|
||||
const result = Binary.search(parts, props.partID, (p) => p.id)
|
||||
@@ -263,6 +279,7 @@ export function applyDirectoryEvent(input: {
|
||||
if (!parts) break
|
||||
const result = Binary.search(parts, props.partID, (p) => p.id)
|
||||
if (!result.found) break
|
||||
input.setStore("part_text_accum_delta", props.partID, (existing) => (existing ?? "") + props.delta)
|
||||
input.setStore(
|
||||
"part",
|
||||
props.messageID,
|
||||
|
||||
@@ -39,6 +39,7 @@ describe("app session cache", () => {
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
part_text_accum_delta: Record<string, string | undefined>
|
||||
} = {
|
||||
session_status: { ses_1: { type: "busy" } as SessionStatus },
|
||||
session_diff: { ses_1: [] },
|
||||
@@ -47,12 +48,14 @@ describe("app session cache", () => {
|
||||
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
||||
permission: { ses_1: [] as PermissionRequest[] },
|
||||
question: { ses_1: [] as QuestionRequest[] },
|
||||
part_text_accum_delta: { prt_1: "streamed text" },
|
||||
}
|
||||
|
||||
dropSessionCaches(store, ["ses_1"])
|
||||
|
||||
expect(store.message.ses_1).toBeUndefined()
|
||||
expect(store.part.msg_1).toBeUndefined()
|
||||
expect(store.part_text_accum_delta.prt_1).toBeUndefined()
|
||||
expect(store.todo.ses_1).toBeUndefined()
|
||||
expect(store.session_diff.ses_1).toBeUndefined()
|
||||
expect(store.session_status.ses_1).toBeUndefined()
|
||||
@@ -70,6 +73,7 @@ describe("app session cache", () => {
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
part_text_accum_delta: Record<string, string | undefined>
|
||||
} = {
|
||||
session_status: {},
|
||||
session_diff: {},
|
||||
@@ -78,6 +82,7 @@ describe("app session cache", () => {
|
||||
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
||||
permission: {},
|
||||
question: {},
|
||||
part_text_accum_delta: {},
|
||||
}
|
||||
|
||||
dropSessionCaches(store, ["ses_1"])
|
||||
|
||||
@@ -18,6 +18,7 @@ type SessionCache = {
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
part_text_accum_delta: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<string>) {
|
||||
@@ -27,6 +28,9 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
|
||||
for (const key of Object.keys(store.part)) {
|
||||
const parts = store.part[key]
|
||||
if (!parts?.some((part) => stale.has(part?.sessionID ?? ""))) continue
|
||||
for (const part of parts) {
|
||||
delete store.part_text_accum_delta[part.id]
|
||||
}
|
||||
delete store.part[key]
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,9 @@ export type State = {
|
||||
part: {
|
||||
[messageID: string]: Part[]
|
||||
}
|
||||
part_text_accum_delta: {
|
||||
[partID: string]: string
|
||||
}
|
||||
}
|
||||
|
||||
export type VcsCache = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.48",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -79,6 +80,7 @@
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export function UsageSection() {
|
||||
}
|
||||
|
||||
const calculateTotalOutputTokens = (u: Awaited<ReturnType<typeof getUsageInfo>>[0]) => {
|
||||
return u.outputTokens + (u.reasoningTokens ?? 0)
|
||||
return u.outputTokens
|
||||
}
|
||||
|
||||
const goPrev = async () => {
|
||||
|
||||
@@ -889,10 +889,6 @@ export async function handler(
|
||||
|
||||
const inputCost = modelCost.input * inputTokens * 100
|
||||
const outputCost = modelCost.output * outputTokens * 100
|
||||
const reasoningCost = (() => {
|
||||
if (!reasoningTokens) return undefined
|
||||
return modelCost.output * reasoningTokens * 100
|
||||
})()
|
||||
const cacheReadCost = (() => {
|
||||
if (!cacheReadTokens) return undefined
|
||||
if (!modelCost.cacheRead) return undefined
|
||||
@@ -909,17 +905,11 @@ export async function handler(
|
||||
return modelCost.cacheWrite1h * cacheWrite1hTokens * 100
|
||||
})()
|
||||
const totalCostInCent =
|
||||
inputCost +
|
||||
outputCost +
|
||||
(reasoningCost ?? 0) +
|
||||
(cacheReadCost ?? 0) +
|
||||
(cacheWrite5mCost ?? 0) +
|
||||
(cacheWrite1hCost ?? 0)
|
||||
inputCost + outputCost + (cacheReadCost ?? 0) + (cacheWrite5mCost ?? 0) + (cacheWrite1hCost ?? 0)
|
||||
return {
|
||||
totalCostInCent,
|
||||
inputCost,
|
||||
outputCost,
|
||||
reasoningCost,
|
||||
cacheReadCost,
|
||||
cacheWrite5mCost,
|
||||
cacheWrite1hCost,
|
||||
@@ -941,8 +931,7 @@ export async function handler(
|
||||
) {
|
||||
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
|
||||
usageInfo
|
||||
const { totalCostInCent, inputCost, outputCost, reasoningCost, cacheReadCost, cacheWrite5mCost, cacheWrite1hCost } =
|
||||
costInfo
|
||||
const { totalCostInCent, inputCost, outputCost, cacheReadCost, cacheWrite5mCost, cacheWrite1hCost } = costInfo
|
||||
|
||||
logger.metric({
|
||||
"tokens.input": inputTokens,
|
||||
@@ -953,14 +942,12 @@ export async function handler(
|
||||
"tokens.cache_write_1h": cacheWrite1hTokens,
|
||||
"cost.input.microcents": centsToMicroCents(inputCost),
|
||||
"cost.output.microcents": centsToMicroCents(outputCost),
|
||||
"cost.reasoning.microcents": reasoningCost ? centsToMicroCents(reasoningCost) : undefined,
|
||||
"cost.cache_read.microcents": cacheReadCost ? centsToMicroCents(cacheReadCost) : undefined,
|
||||
"cost.cache_write.microcents": cacheWrite5mCost ? centsToMicroCents(cacheWrite5mCost) : undefined,
|
||||
"cost.total.microcents": centsToMicroCents(totalCostInCent),
|
||||
// deprecated - remove after May 20, 2026
|
||||
"cost.input": Math.round(inputCost),
|
||||
"cost.output": Math.round(outputCost),
|
||||
"cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined,
|
||||
"cost.cache_read": cacheReadCost ? Math.round(cacheReadCost) : undefined,
|
||||
"cost.cache_write_5m": cacheWrite5mCost ? Math.round(cacheWrite5mCost) : undefined,
|
||||
"cost.cache_write_1h": cacheWrite1hCost ? Math.round(cacheWrite1hCost) : undefined,
|
||||
|
||||
@@ -50,7 +50,7 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
|
||||
const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined
|
||||
return {
|
||||
inputTokens: inputTokens - (cacheReadTokens ?? 0),
|
||||
outputTokens: outputTokens - (reasoningTokens ?? 0),
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
cacheReadTokens,
|
||||
cacheWrite5mTokens: undefined,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.48",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.48",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.48",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.48",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -36,7 +36,7 @@ export function zod<S extends Schema.Top>(schema: S): z.ZodType<Schema.Schema.Ty
|
||||
* mapped `.omit()` / `.extend()` surface triggers brand-intersection
|
||||
* explosions for branded primitives (`string & Brand<"SessionID">` extends
|
||||
* `object` via the brand and gets walked into the prototype by `DeepPartial`,
|
||||
* `updateSchema`, etc.), and zod's inference through `z.ZodType<T | undefined>`
|
||||
* mapped-schema helpers, and zod's inference through `z.ZodType<T | undefined>`
|
||||
* wrappers also can't reconstruct `T` cleanly. Consumers that care about the
|
||||
* post-`.omit()` shape should cast `c.req.valid(...)` to the expected type.
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.48",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.48",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.14.46"
|
||||
version = "1.14.48"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.48",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -160,9 +160,9 @@ const program = Effect.gen(function* () {
|
||||
|
||||
## Inspecting cassettes programmatically
|
||||
|
||||
`Cassette.Service` exposes `read`, `write`, `append`, `exists`, `list`, and
|
||||
`scan` (re-running the secret detector over an existing cassette). Useful
|
||||
for CI checks:
|
||||
`Cassette.Service` exposes `read`, `append`, `exists`, and `list`. `read`
|
||||
returns the recorded interactions for a name; the file format is hidden
|
||||
behind the seam. Useful for CI checks:
|
||||
|
||||
```ts
|
||||
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||
@@ -170,13 +170,22 @@ import { Effect } from "effect"
|
||||
|
||||
const audit = Effect.gen(function* () {
|
||||
const cassettes = yield* HttpRecorder.Cassette.Service
|
||||
const findings = yield* Effect.forEach(yield* cassettes.list(), (entry) =>
|
||||
cassettes.read(entry.name).pipe(Effect.map((c) => ({ entry, findings: cassettes.scan(c) }))),
|
||||
const entries = yield* cassettes.list()
|
||||
const issues = yield* Effect.forEach(entries, (entry) =>
|
||||
cassettes
|
||||
.read(entry.name)
|
||||
.pipe(Effect.map((interactions) => ({ name: entry.name, findings: HttpRecorder.secretFindings(interactions) }))),
|
||||
)
|
||||
return findings.filter((r) => r.findings.length > 0)
|
||||
return issues.filter((i) => i.findings.length > 0)
|
||||
})
|
||||
```
|
||||
|
||||
`cassetteLayer` is the batteries-included entry point — it provides
|
||||
`Cassette.fileSystem({ directory })` automatically. If you want to provide
|
||||
your own `Cassette.Service` (e.g. an in-memory adapter for the recorder's
|
||||
own unit tests), use `recordingLayer` and supply `Cassette.fileSystem` /
|
||||
`Cassette.memory` yourself.
|
||||
|
||||
## Options reference
|
||||
|
||||
```ts
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.48",
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,62 +1,76 @@
|
||||
import { Context, Effect, FileSystem, Layer, PlatformError } from "effect"
|
||||
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { cassetteSecretFindings, secretFindings, type SecretFinding } from "./redaction"
|
||||
import type { Cassette, CassetteMetadata, Interaction } from "./schema"
|
||||
import { cassetteFor, cassettePath, DEFAULT_RECORDINGS_DIR, formatCassette, parseCassette } from "./storage"
|
||||
import { secretFindings, type SecretFinding } from "./redaction"
|
||||
import { decodeCassette, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema"
|
||||
|
||||
export interface Entry {
|
||||
readonly name: string
|
||||
readonly path: string
|
||||
const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
|
||||
|
||||
export class CassetteNotFoundError extends Schema.TaggedErrorClass<CassetteNotFoundError>()("CassetteNotFoundError", {
|
||||
cassetteName: Schema.String,
|
||||
}) {
|
||||
override get message() {
|
||||
return `Cassette "${this.cassetteName}" not found`
|
||||
}
|
||||
}
|
||||
|
||||
export interface AppendResult {
|
||||
readonly findings: ReadonlyArray<SecretFinding>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly path: (name: string) => string
|
||||
readonly read: (name: string) => Effect.Effect<Cassette, PlatformError.PlatformError>
|
||||
readonly write: (name: string, cassette: Cassette) => Effect.Effect<void, PlatformError.PlatformError>
|
||||
readonly append: (
|
||||
name: string,
|
||||
interaction: Interaction,
|
||||
metadata: CassetteMetadata | undefined,
|
||||
) => Effect.Effect<
|
||||
{
|
||||
readonly cassette: Cassette
|
||||
readonly findings: ReadonlyArray<SecretFinding>
|
||||
},
|
||||
PlatformError.PlatformError
|
||||
>
|
||||
readonly read: (name: string) => Effect.Effect<ReadonlyArray<Interaction>, CassetteNotFoundError>
|
||||
readonly append: (name: string, interaction: Interaction, metadata?: CassetteMetadata) => Effect.Effect<AppendResult>
|
||||
readonly exists: (name: string) => Effect.Effect<boolean>
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<Entry>, PlatformError.PlatformError>
|
||||
readonly scan: (cassette: Cassette) => ReadonlyArray<SecretFinding>
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<string>>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/http-recorder/Cassette") {}
|
||||
|
||||
export const layer = (options: { readonly directory?: string } = {}) =>
|
||||
export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) =>
|
||||
fs.existsSync(path.join(options.directory ?? DEFAULT_RECORDINGS_DIR, `${name}.json`))
|
||||
|
||||
const buildCassette = (
|
||||
name: string,
|
||||
interactions: ReadonlyArray<Interaction>,
|
||||
metadata: CassetteMetadata | undefined,
|
||||
): Cassette => ({
|
||||
version: 1,
|
||||
metadata: { name, recordedAt: new Date().toISOString(), ...(metadata ?? {}) },
|
||||
interactions,
|
||||
})
|
||||
|
||||
const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
|
||||
|
||||
const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw))
|
||||
|
||||
export const fileSystem = (
|
||||
options: { readonly directory?: string } = {},
|
||||
): Layer.Layer<Service, never, FileSystem.FileSystem> =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fileSystem = yield* FileSystem.FileSystem
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
|
||||
const recorded = new Map<string, { interactions: Interaction[]; findings: SecretFinding[] }>()
|
||||
const directoriesEnsured = new Set<string>()
|
||||
|
||||
const pathFor = (name: string) => cassettePath(name, directory)
|
||||
const cassettePath = (name: string) => path.join(directory, `${name}.json`)
|
||||
|
||||
const ensureDirectory = Effect.fn("Cassette.ensureDirectory")(function* (name: string) {
|
||||
const dir = path.dirname(pathFor(name))
|
||||
if (directoriesEnsured.has(dir)) return
|
||||
yield* fileSystem.makeDirectory(dir, { recursive: true })
|
||||
directoriesEnsured.add(dir)
|
||||
})
|
||||
|
||||
const walk = (directory: string): Effect.Effect<ReadonlyArray<string>, PlatformError.PlatformError> =>
|
||||
const ensureDirectory = (name: string) =>
|
||||
Effect.gen(function* () {
|
||||
const entries = yield* fileSystem
|
||||
.readDirectory(directory)
|
||||
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
const dir = path.dirname(cassettePath(name))
|
||||
if (directoriesEnsured.has(dir)) return
|
||||
yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie)
|
||||
directoriesEnsured.add(dir)
|
||||
})
|
||||
|
||||
const walk = (current: string): Effect.Effect<ReadonlyArray<string>> =>
|
||||
Effect.gen(function* () {
|
||||
const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
const nested = yield* Effect.forEach(entries, (entry) => {
|
||||
const full = path.join(directory, entry)
|
||||
return fileSystem.stat(full).pipe(
|
||||
const full = path.join(current, entry)
|
||||
return fs.stat(full).pipe(
|
||||
Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
|
||||
Effect.catch(() => Effect.succeed([] as string[])),
|
||||
)
|
||||
@@ -64,50 +78,73 @@ export const layer = (options: { readonly directory?: string } = {}) =>
|
||||
return nested.flat()
|
||||
})
|
||||
|
||||
const read = Effect.fn("Cassette.read")(function* (name: string) {
|
||||
return parseCassette(yield* fileSystem.readFileString(pathFor(name)))
|
||||
return Service.of({
|
||||
read: (name) =>
|
||||
fs.readFileString(cassettePath(name)).pipe(
|
||||
Effect.map((raw) => parseCassette(raw).interactions),
|
||||
Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))),
|
||||
),
|
||||
append: (name, interaction, metadata) =>
|
||||
Effect.gen(function* () {
|
||||
const entry = recorded.get(name) ?? { interactions: [], findings: [] }
|
||||
if (!recorded.has(name)) recorded.set(name, entry)
|
||||
entry.interactions.push(interaction)
|
||||
entry.findings.push(...secretFindings(interaction))
|
||||
const cassette = buildCassette(name, entry.interactions, metadata)
|
||||
const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})]
|
||||
if (findings.length === 0) {
|
||||
yield* ensureDirectory(name)
|
||||
yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie)
|
||||
}
|
||||
return { findings }
|
||||
}),
|
||||
exists: (name) =>
|
||||
fs.access(cassettePath(name)).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
),
|
||||
list: () =>
|
||||
walk(directory).pipe(
|
||||
Effect.map((files) =>
|
||||
files
|
||||
.filter((file) => file.endsWith(".json"))
|
||||
.map((file) =>
|
||||
path
|
||||
.relative(directory, file)
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/\.json$/, ""),
|
||||
)
|
||||
.toSorted((a, b) => a.localeCompare(b)),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
const write = Effect.fn("Cassette.write")(function* (name: string, cassette: Cassette) {
|
||||
yield* ensureDirectory(name)
|
||||
yield* fileSystem.writeFileString(pathFor(name), formatCassette(cassette))
|
||||
})
|
||||
|
||||
const append = Effect.fn("Cassette.append")(function* (
|
||||
name: string,
|
||||
interaction: Interaction,
|
||||
metadata: CassetteMetadata | undefined,
|
||||
) {
|
||||
const entry = recorded.get(name) ?? { interactions: [], findings: [] }
|
||||
entry.interactions.push(interaction)
|
||||
entry.findings.push(...secretFindings(interaction))
|
||||
recorded.set(name, entry)
|
||||
const cassette = cassetteFor(name, entry.interactions, metadata)
|
||||
const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})]
|
||||
if (findings.length === 0) yield* write(name, cassette)
|
||||
return { cassette, findings }
|
||||
})
|
||||
|
||||
const exists = Effect.fn("Cassette.exists")(function* (name: string) {
|
||||
return yield* fileSystem.access(pathFor(name)).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
)
|
||||
})
|
||||
|
||||
const list = Effect.fn("Cassette.list")(function* () {
|
||||
return (yield* walk(directory))
|
||||
.filter((file) => file.endsWith(".json"))
|
||||
.map((file) => ({
|
||||
name: path
|
||||
.relative(directory, file)
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/\.json$/, ""),
|
||||
path: file,
|
||||
}))
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
|
||||
return Service.of({ path: pathFor, read, write, append, exists, list, scan: cassetteSecretFindings })
|
||||
}),
|
||||
)
|
||||
|
||||
export const memory = (initial: Record<string, ReadonlyArray<Interaction>> = {}): Layer.Layer<Service> =>
|
||||
Layer.sync(Service, () => {
|
||||
const stored = new Map<string, Interaction[]>(
|
||||
Object.entries(initial).map(([name, interactions]) => [name, [...interactions]]),
|
||||
)
|
||||
const accumulatedFindings = new Map<string, SecretFinding[]>()
|
||||
|
||||
return Service.of({
|
||||
read: (name) =>
|
||||
stored.has(name)
|
||||
? Effect.succeed(stored.get(name) ?? [])
|
||||
: Effect.fail(new CassetteNotFoundError({ cassetteName: name })),
|
||||
append: (name, interaction, metadata) =>
|
||||
Effect.sync(() => {
|
||||
const existing = stored.get(name)
|
||||
if (existing) existing.push(interaction)
|
||||
else stored.set(name, [interaction])
|
||||
const findings = accumulatedFindings.get(name)
|
||||
if (findings) findings.push(...secretFindings(interaction))
|
||||
else accumulatedFindings.set(name, [...secretFindings(interaction)])
|
||||
if (metadata) accumulatedFindings.get(name)!.push(...secretFindings({ name, ...metadata }))
|
||||
return { findings: accumulatedFindings.get(name) ?? [] }
|
||||
}),
|
||||
exists: (name) => Effect.sync(() => stored.has(name)),
|
||||
list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -138,7 +138,7 @@ export const recordingLayer = (
|
||||
|
||||
export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer<HttpClient.HttpClient> =>
|
||||
recordingLayer(name, options).pipe(
|
||||
Layer.provide(CassetteService.layer({ directory: options.directory })),
|
||||
Layer.provide(CassetteService.fileSystem({ directory: options.directory })),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
)
|
||||
|
||||
@@ -7,9 +7,9 @@ export type {
|
||||
WebSocketFrame,
|
||||
WebSocketInteraction,
|
||||
} from "./schema"
|
||||
export { hasCassetteSync } from "./storage"
|
||||
export { CassetteNotFoundError, hasCassetteSync } from "./cassette"
|
||||
export { defaultMatcher, type RequestMatcher } from "./matching"
|
||||
export { cassetteSecretFindings, redactHeaders, redactUrl, type SecretFinding } from "./redaction"
|
||||
export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction"
|
||||
export { UnsafeCassetteError } from "./recorder"
|
||||
export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./effect"
|
||||
export {
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import { Effect, PlatformError, Ref, Scope } from "effect"
|
||||
import { Effect, Ref, Schema, Scope } from "effect"
|
||||
import type * as CassetteService from "./cassette"
|
||||
import type { SecretFinding } from "./redaction"
|
||||
import type { Cassette, CassetteMetadata, Interaction } from "./schema"
|
||||
import type { CassetteNotFoundError } from "./cassette"
|
||||
import { SecretFindingSchema } from "./redaction"
|
||||
import type { CassetteMetadata, Interaction } from "./schema"
|
||||
|
||||
export class UnsafeCassetteError extends Error {
|
||||
readonly _tag = "UnsafeCassetteError"
|
||||
constructor(
|
||||
readonly cassetteName: string,
|
||||
readonly findings: ReadonlyArray<SecretFinding>,
|
||||
) {
|
||||
super(
|
||||
`Refusing to write cassette "${cassetteName}" because it contains possible secrets: ${findings
|
||||
.map((finding) => `${finding.path} (${finding.reason})`)
|
||||
.join(", ")}`,
|
||||
)
|
||||
export class UnsafeCassetteError extends Schema.TaggedErrorClass<UnsafeCassetteError>()("UnsafeCassetteError", {
|
||||
cassetteName: Schema.String,
|
||||
findings: Schema.Array(SecretFindingSchema),
|
||||
}) {
|
||||
override get message() {
|
||||
return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings
|
||||
.map((finding) => `${finding.path} (${finding.reason})`)
|
||||
.join(", ")}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,16 +33,17 @@ export const appendOrFail = (
|
||||
name: string,
|
||||
interaction: Interaction,
|
||||
metadata: CassetteMetadata | undefined,
|
||||
): Effect.Effect<Cassette, UnsafeCassetteError> =>
|
||||
cassette.append(name, interaction, metadata).pipe(
|
||||
Effect.orDie,
|
||||
Effect.flatMap(({ cassette: result, findings }) =>
|
||||
findings.length === 0 ? Effect.succeed(result) : Effect.fail(new UnsafeCassetteError(name, findings)),
|
||||
),
|
||||
)
|
||||
): Effect.Effect<void, UnsafeCassetteError> =>
|
||||
cassette
|
||||
.append(name, interaction, metadata)
|
||||
.pipe(
|
||||
Effect.flatMap(({ findings }) =>
|
||||
findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings })),
|
||||
),
|
||||
)
|
||||
|
||||
export interface ReplayState<T> {
|
||||
readonly load: Effect.Effect<ReadonlyArray<T>, PlatformError.PlatformError>
|
||||
readonly load: Effect.Effect<ReadonlyArray<T>, CassetteNotFoundError>
|
||||
readonly cursor: Effect.Effect<number>
|
||||
readonly advance: Effect.Effect<void>
|
||||
}
|
||||
@@ -52,7 +51,7 @@ export interface ReplayState<T> {
|
||||
export const makeReplayState = <T>(
|
||||
cassette: CassetteService.Interface,
|
||||
name: string,
|
||||
project: (cassette: Cassette) => ReadonlyArray<T>,
|
||||
project: (interactions: ReadonlyArray<Interaction>) => ReadonlyArray<T>,
|
||||
): Effect.Effect<ReplayState<T>, never, Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project)))
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { Cassette } from "./schema"
|
||||
|
||||
export const REDACTED = "[REDACTED]"
|
||||
|
||||
const DEFAULT_REDACT_HEADERS = [
|
||||
@@ -97,10 +95,13 @@ export const redactHeaders = (
|
||||
)
|
||||
}
|
||||
|
||||
export type SecretFinding = {
|
||||
readonly path: string
|
||||
readonly reason: string
|
||||
}
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const SecretFindingSchema = Schema.Struct({
|
||||
path: Schema.String,
|
||||
reason: Schema.String,
|
||||
})
|
||||
export type SecretFinding = Schema.Schema.Type<typeof SecretFindingSchema>
|
||||
|
||||
export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> =>
|
||||
stringEntries(value).flatMap((entry) => [
|
||||
@@ -112,5 +113,3 @@ export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> =>
|
||||
.filter((item) => entry.value.includes(item.value))
|
||||
.map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })),
|
||||
])
|
||||
|
||||
export const cassetteSecretFindings = (cassette: Cassette) => secretFindings(cassette)
|
||||
|
||||
@@ -52,9 +52,10 @@ export const isHttpInteraction = InteractionSchema.guards.http
|
||||
|
||||
export const isWebSocketInteraction = InteractionSchema.guards.websocket
|
||||
|
||||
export const httpInteractions = (cassette: Cassette) => cassette.interactions.filter(isHttpInteraction)
|
||||
export const httpInteractions = (interactions: ReadonlyArray<Interaction>) => interactions.filter(isHttpInteraction)
|
||||
|
||||
export const webSocketInteractions = (cassette: Cassette) => cassette.interactions.filter(isWebSocketInteraction)
|
||||
export const webSocketInteractions = (interactions: ReadonlyArray<Interaction>) =>
|
||||
interactions.filter(isWebSocketInteraction)
|
||||
|
||||
export const CassetteSchema = Schema.Struct({
|
||||
version: Schema.Literal(1),
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Option } from "effect"
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { encodeCassette, decodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema"
|
||||
|
||||
export const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
|
||||
|
||||
export const cassettePath = (name: string, directory = DEFAULT_RECORDINGS_DIR) => path.join(directory, `${name}.json`)
|
||||
|
||||
export const cassetteFor = (
|
||||
name: string,
|
||||
interactions: ReadonlyArray<Interaction>,
|
||||
metadata: CassetteMetadata | undefined,
|
||||
): Cassette => ({
|
||||
version: 1,
|
||||
metadata: { name, recordedAt: new Date().toISOString(), ...(metadata ?? {}) },
|
||||
interactions,
|
||||
})
|
||||
|
||||
export const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
|
||||
|
||||
export const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw))
|
||||
|
||||
export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) => {
|
||||
const file = cassettePath(name, options.directory)
|
||||
if (!fs.existsSync(file)) return false
|
||||
return Option.isSome(Option.liftThrowable(parseCassette)(fs.readFileSync(file, "utf8")))
|
||||
}
|
||||
@@ -7,7 +7,15 @@ import * as os from "node:os"
|
||||
import * as path from "node:path"
|
||||
import { HttpRecorder } from "../src"
|
||||
import { redactedErrorRequest } from "../src/effect"
|
||||
import { cassetteFor, formatCassette, parseCassette } from "../src/storage"
|
||||
import type { Interaction } from "../src/schema"
|
||||
|
||||
const seedCassetteDirectory = (directory: string, name: string, interactions: ReadonlyArray<Interaction>) =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const cassette = yield* HttpRecorder.Cassette.Service
|
||||
yield* Effect.forEach(interactions, (interaction) => cassette.append(name, interaction))
|
||||
}).pipe(Effect.provide(HttpRecorder.Cassette.fileSystem({ directory })), Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
|
||||
const post = (url: string, body: object) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -34,7 +42,7 @@ const runRecorder = <A, E>(effect: Effect.Effect<A, E, HttpRecorder.Cassette.Ser
|
||||
Effect.scoped(
|
||||
effect.pipe(
|
||||
Effect.provide(
|
||||
HttpRecorder.Cassette.layer({ directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")) }),
|
||||
HttpRecorder.Cassette.fileSystem({ directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")) }),
|
||||
),
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
),
|
||||
@@ -109,7 +117,7 @@ describe("http-recorder", () => {
|
||||
|
||||
test("detects secret-looking values without returning the secret", () => {
|
||||
expect(
|
||||
HttpRecorder.cassetteSecretFindings({
|
||||
HttpRecorder.secretFindings({
|
||||
version: 1,
|
||||
interactions: [
|
||||
{
|
||||
@@ -137,7 +145,7 @@ describe("http-recorder", () => {
|
||||
|
||||
test("detects secret-looking values inside metadata", () => {
|
||||
expect(
|
||||
HttpRecorder.cassetteSecretFindings({
|
||||
HttpRecorder.secretFindings({
|
||||
version: 1,
|
||||
metadata: { token: "sk-123456789012345678901234" },
|
||||
interactions: [],
|
||||
@@ -145,60 +153,42 @@ describe("http-recorder", () => {
|
||||
).toEqual([{ path: "metadata.token", reason: "API key" }])
|
||||
})
|
||||
|
||||
test("formats websocket cassettes with shared metadata", () => {
|
||||
const cassette = cassetteFor(
|
||||
"websocket/basic",
|
||||
[
|
||||
{
|
||||
transport: "websocket",
|
||||
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
|
||||
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
|
||||
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
|
||||
},
|
||||
],
|
||||
{ provider: "openai" },
|
||||
)
|
||||
test("replays websocket interactions seeded into the in-memory cassette adapter", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const cassette = yield* HttpRecorder.Cassette.Service
|
||||
const executor = yield* HttpRecorder.makeWebSocketExecutor({
|
||||
name: "websocket/replay",
|
||||
cassette,
|
||||
compareClientMessagesAsJson: true,
|
||||
live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
|
||||
})
|
||||
const connection = yield* executor.open({
|
||||
url: "wss://example.test/realtime",
|
||||
headers: Headers.fromInput({ "content-type": "application/json" }),
|
||||
})
|
||||
yield* connection.sendText(JSON.stringify({ type: "response.create" }))
|
||||
const messages: Array<string | Uint8Array> = []
|
||||
yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message))))
|
||||
yield* connection.close
|
||||
|
||||
expect(cassette.metadata).toMatchObject({ name: "websocket/basic", provider: "openai" })
|
||||
expect(parseCassette(formatCassette(cassette))).toEqual(cassette)
|
||||
})
|
||||
|
||||
test("replays websocket interactions from the shared cassette service", async () => {
|
||||
await runRecorder(
|
||||
Effect.gen(function* () {
|
||||
const cassette = yield* HttpRecorder.Cassette.Service
|
||||
yield* cassette.write(
|
||||
"websocket/replay",
|
||||
cassetteFor(
|
||||
"websocket/replay",
|
||||
[
|
||||
{
|
||||
transport: "websocket",
|
||||
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
|
||||
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
|
||||
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
expect(messages).toEqual([JSON.stringify({ type: "response.completed" })])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
HttpRecorder.Cassette.memory({
|
||||
"websocket/replay": [
|
||||
{
|
||||
transport: "websocket",
|
||||
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
|
||||
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
|
||||
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
)
|
||||
const executor = yield* HttpRecorder.makeWebSocketExecutor({
|
||||
name: "websocket/replay",
|
||||
cassette,
|
||||
compareClientMessagesAsJson: true,
|
||||
live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
|
||||
})
|
||||
const connection = yield* executor.open({
|
||||
url: "wss://example.test/realtime",
|
||||
headers: Headers.fromInput({ "content-type": "application/json" }),
|
||||
})
|
||||
yield* connection.sendText(JSON.stringify({ type: "response.create" }))
|
||||
const messages: Array<string | Uint8Array> = []
|
||||
yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message))))
|
||||
yield* connection.close
|
||||
|
||||
expect(messages).toEqual([JSON.stringify({ type: "response.completed" })])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -228,17 +218,14 @@ describe("http-recorder", () => {
|
||||
yield* connection.messages.pipe(Stream.runDrain)
|
||||
yield* connection.close
|
||||
|
||||
expect(yield* cassette.read("websocket/record")).toMatchObject({
|
||||
metadata: { name: "websocket/record", provider: "test" },
|
||||
interactions: [
|
||||
{
|
||||
transport: "websocket",
|
||||
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
|
||||
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
|
||||
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(yield* cassette.read("websocket/record")).toMatchObject([
|
||||
{
|
||||
transport: "websocket",
|
||||
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
|
||||
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
|
||||
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -303,28 +290,18 @@ describe("http-recorder", () => {
|
||||
|
||||
test("auto mode replays when the cassette exists", async () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-"))
|
||||
const cassettePath = path.join(directory, "auto-replay.json")
|
||||
fs.writeFileSync(
|
||||
cassettePath,
|
||||
formatCassette(
|
||||
cassetteFor(
|
||||
"auto-replay",
|
||||
[
|
||||
{
|
||||
transport: "http",
|
||||
request: {
|
||||
method: "POST",
|
||||
url: "https://example.test/echo",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ step: 1 }),
|
||||
},
|
||||
response: { status: 200, headers: { "content-type": "application/json" }, body: '{"reply":"hi"}' },
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
),
|
||||
),
|
||||
)
|
||||
await seedCassetteDirectory(directory, "auto-replay", [
|
||||
{
|
||||
transport: "http",
|
||||
request: {
|
||||
method: "POST",
|
||||
url: "https://example.test/echo",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ step: 1 }),
|
||||
},
|
||||
response: { status: 200, headers: { "content-type": "application/json" }, body: '{"reply":"hi"}' },
|
||||
},
|
||||
])
|
||||
|
||||
const result = await runWith(
|
||||
"auto-replay",
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# @opencode-ai/llm
|
||||
|
||||
Schema-first LLM core for opencode. One typed request, response, event, and tool language; provider quirks live in adapters, not in calling code.
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMClient } from "@opencode-ai/llm"
|
||||
import { OpenAI } from "@opencode-ai/llm/providers"
|
||||
|
||||
const model = OpenAI.model("gpt-4o-mini", { apiKey: process.env.OPENAI_API_KEY })
|
||||
|
||||
const request = LLM.request({
|
||||
model,
|
||||
system: "You are concise.",
|
||||
prompt: "Say hello in one short sentence.",
|
||||
generation: { maxTokens: 40 },
|
||||
})
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request)
|
||||
console.log(response.text)
|
||||
})
|
||||
```
|
||||
|
||||
Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
|
||||
|
||||
## Public API
|
||||
|
||||
- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes.
|
||||
- **`LLM.generate` / `LLM.stream`** — re-exported from `LLMClient` for one-import use.
|
||||
- **`LLM.user(...)` / `LLM.assistant(...)` / `LLM.toolMessage(...)`** — message constructors.
|
||||
- **`LLM.toolCall(...)` / `LLM.toolResult(...)` / `LLM.toolDefinition(...)`** — tool-related parts.
|
||||
- **`LLMClient.prepare(request)`** — compile a request through protocol body construction, validation, and HTTP preparation without sending. Useful for inspection and testing.
|
||||
- **`LLMEvent.is.*`** — typed guards (`is.text`, `is.toolCall`, `is.requestFinish`, …) for filtering streams.
|
||||
|
||||
## Caching
|
||||
|
||||
Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "auto"` unless the caller opts out with `cache: "none"`. Each protocol translates `CacheHint`s to its wire format (`cache_control` on Anthropic, `cachePoint` on Bedrock; OpenAI and Gemini do implicit caching server-side and don't need inline markers — auto is a no-op there).
|
||||
|
||||
### Auto placement
|
||||
|
||||
`"auto"` places three breakpoints — last tool definition, last system part, latest user message. The last-user-message boundary is the load-bearing detail: in a tool-use loop, a single user turn expands into many assistant/tool round-trips, all sharing that prefix. Caching at that boundary lets every intra-turn API call hit.
|
||||
|
||||
The math justifies the default: Anthropic's 5-minute cache write is 1.25× base, read is 0.1×, so a single reuse within 5 minutes already wins. One-shot completions below the per-model minimum-cacheable-token threshold silently no-op on the wire, so the worst case is harmless.
|
||||
|
||||
### Opting out
|
||||
|
||||
```ts
|
||||
LLM.request({
|
||||
model,
|
||||
system,
|
||||
prompt: "one-off question",
|
||||
cache: "none",
|
||||
})
|
||||
```
|
||||
|
||||
### Granular policy
|
||||
|
||||
```ts
|
||||
cache: {
|
||||
tools?: boolean,
|
||||
system?: boolean,
|
||||
messages?: "latest-user-message" | "latest-assistant" | { tail: number },
|
||||
ttlSeconds?: number, // ≥ 3600 → 1h on Anthropic/Bedrock; else 5m
|
||||
}
|
||||
```
|
||||
|
||||
### Manual hints
|
||||
|
||||
Inline `CacheHint` on any text / system / tool / tool-result part overrides automatic placement. The auto policy preserves manual hints; it only fills gaps.
|
||||
|
||||
```ts
|
||||
LLM.request({
|
||||
model,
|
||||
system: [
|
||||
{ type: "text", text: "stable system prompt", cache: { type: "ephemeral" } },
|
||||
],
|
||||
...
|
||||
})
|
||||
```
|
||||
|
||||
### Provider behavior table
|
||||
|
||||
| Protocol | `cache: "auto"` |
|
||||
| ----------------------- | ------------------------------------------------------------------------- |
|
||||
| Anthropic Messages | emits up to 3 `cache_control` markers (4-breakpoint cap enforced) |
|
||||
| Bedrock Converse | emits up to 3 `cachePoint` blocks (4-breakpoint cap enforced) |
|
||||
| OpenAI Chat / Responses | no-op (implicit caching above 1024 tokens) |
|
||||
| Gemini | no-op (implicit caching on 2.5+; explicit `CachedContent` is out-of-band) |
|
||||
|
||||
Normalized cache usage is read back into `response.usage.cacheReadInputTokens` and `cacheWriteInputTokens` across every provider.
|
||||
|
||||
## Providers
|
||||
|
||||
Each provider exports a `model(...)` helper that records identity, protocol, capabilities, auth, and defaults.
|
||||
|
||||
```ts
|
||||
import { Anthropic } from "@opencode-ai/llm/providers"
|
||||
|
||||
const model = Anthropic.model("claude-sonnet-4-6", {
|
||||
apiKey: process.env.ANTHROPIC_API_KEY,
|
||||
})
|
||||
```
|
||||
|
||||
Included providers: OpenAI, Anthropic, Google (Gemini), Amazon Bedrock, Azure OpenAI, Cloudflare, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible helpers for DeepSeek, Cerebras, Groq, Fireworks, Together, etc.
|
||||
|
||||
## Provider options & HTTP overlays
|
||||
|
||||
Three escape hatches in order of stability:
|
||||
|
||||
1. **`generation`** — portable knobs (`maxTokens`, `temperature`, `topP`, `topK`, penalties, seed, stop).
|
||||
2. **`providerOptions: { <provider>: {...} }`** — typed-at-the-facade provider-specific knobs (OpenAI `promptCacheKey`, Anthropic `thinking`, Gemini `thinkingConfig`, OpenRouter routing).
|
||||
3. **`http: { body, headers, query }`** — last-resort serializable overlays merged into the final HTTP request. Reach for this only when a stable typed path doesn't yet exist.
|
||||
|
||||
Model-level defaults are overridden by request-level values for each axis.
|
||||
|
||||
## Routes
|
||||
|
||||
Adding a new model or deployment is usually 5–15 lines using `Route.make({ protocol, transport, ... })`. The four orthogonal pieces are protocol (body construction + stream parsing), transport (endpoint + auth + framing + encoding), defaults, and capabilities. See `AGENTS.md` for the architectural detail.
|
||||
|
||||
## Effect
|
||||
|
||||
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` (the default registers every shipped route) for runtime dispatch. The example at `example/tutorial.ts` is a runnable walkthrough.
|
||||
|
||||
## See also
|
||||
|
||||
- `AGENTS.md` — architecture, route construction, contributor guide
|
||||
- `example/tutorial.ts` — runnable end-to-end walkthrough
|
||||
- `test/provider/*.test.ts` — fixture-first protocol tests; `*.recorded.test.ts` files cover live cassettes
|
||||
@@ -184,7 +184,7 @@ const FakeProtocol = Protocol.make<FakeBody, string, string, void>({
|
||||
stream: {
|
||||
event: Schema.String,
|
||||
initial: () => undefined,
|
||||
step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", text: frame }]] as const),
|
||||
step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", id: "text-0", text: frame }]] as const),
|
||||
onHalt: () => [{ type: "request-finish", reason: "stop" }],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.48",
|
||||
"name": "@opencode-ai/llm",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// Apply an `LLMRequest.cache` policy by injecting `CacheHint`s onto the parts
|
||||
// the policy designates. Runs once at compile time, before the per-protocol
|
||||
// body builder, so the existing inline-hint lowering path handles the rest.
|
||||
//
|
||||
// The default `"auto"` shape places one breakpoint at the last tool definition,
|
||||
// one at the last system part, and one at the latest user message. This
|
||||
// matches what production agent harnesses (LangChain's caching middleware,
|
||||
// kern-ai's 10x cost-reduction playbook) converge on for tool-use loops: the
|
||||
// latest user message stays put while a single turn explodes into many
|
||||
// assistant/tool round-trips, so caching at that boundary lets every
|
||||
// intra-turn API call hit the prefix.
|
||||
//
|
||||
// Manual `cache: CacheHint` placements on individual parts are preserved —
|
||||
// this function only fills gaps the caller left empty.
|
||||
import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options"
|
||||
import { LLMRequest, Message, ToolDefinition, type ContentPart } from "./schema/messages"
|
||||
|
||||
const AUTO: CachePolicyObject = {
|
||||
tools: true,
|
||||
system: true,
|
||||
messages: "latest-user-message",
|
||||
}
|
||||
|
||||
const NONE: CachePolicyObject = {}
|
||||
|
||||
// Resolution rules:
|
||||
// - undefined → "auto" — caching is on by default. The math favors it:
|
||||
// Anthropic 5m-cache write is 1.25x base, read is 0.1x,
|
||||
// so a single reuse within 5 minutes already wins.
|
||||
// - "auto" → tools + system + latest user msg.
|
||||
// - "none" → no auto placement; manual `CacheHint`s still flow.
|
||||
// - object form → exactly what the caller asked for.
|
||||
const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
|
||||
if (policy === undefined || policy === "auto") return AUTO
|
||||
if (policy === "none") return NONE
|
||||
return policy
|
||||
}
|
||||
|
||||
// Protocols whose wire format ignores inline cache markers (OpenAI's implicit
|
||||
// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the
|
||||
// whole policy pass for these — emitting hints would be harmless but pointless.
|
||||
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse"])
|
||||
|
||||
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
|
||||
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
|
||||
|
||||
const markLastTool = (tools: ReadonlyArray<ToolDefinition>, hint: CacheHint): ReadonlyArray<ToolDefinition> => {
|
||||
if (tools.length === 0) return tools
|
||||
const last = tools.length - 1
|
||||
if (tools[last]!.cache) return tools
|
||||
return tools.map((tool, i) => (i === last ? new ToolDefinition({ ...tool, cache: hint }) : tool))
|
||||
}
|
||||
|
||||
const markLastSystem = (system: LLMRequest["system"], hint: CacheHint): LLMRequest["system"] => {
|
||||
if (system.length === 0) return system
|
||||
const last = system.length - 1
|
||||
if (system[last]!.cache) return system
|
||||
return system.map((part, i) => (i === last ? { ...part, cache: hint } : part))
|
||||
}
|
||||
|
||||
const lastIndexOfRole = (messages: ReadonlyArray<Message>, role: Message["role"]): number =>
|
||||
messages.findLastIndex((m) => m.role === role)
|
||||
|
||||
// Mark the last text part of `messages[index]`. If no text part exists, mark
|
||||
// the last content part regardless of type — that's the breakpoint position
|
||||
// in tool-result-only messages too.
|
||||
const markMessageAt = (messages: ReadonlyArray<Message>, index: number, hint: CacheHint): ReadonlyArray<Message> => {
|
||||
if (index < 0 || index >= messages.length) return messages
|
||||
const target = messages[index]!
|
||||
if (target.content.length === 0) return messages
|
||||
const lastTextIndex = target.content.findLastIndex((part) => part.type === "text")
|
||||
const markAt = lastTextIndex >= 0 ? lastTextIndex : target.content.length - 1
|
||||
const existing = target.content[markAt]!
|
||||
if ("cache" in existing && existing.cache) return messages
|
||||
const nextContent = target.content.map((part, i) => (i === markAt ? ({ ...part, cache: hint } as ContentPart) : part))
|
||||
const next = new Message({ ...target, content: nextContent })
|
||||
// Single pass over `messages`, substituting the one updated entry. Long
|
||||
// conversations call this on every request, so avoid `.map()` here — its
|
||||
// closure dispatch and identity copies show up in profiling.
|
||||
const result = messages.slice()
|
||||
result[index] = next
|
||||
return result
|
||||
}
|
||||
|
||||
const markMessages = (
|
||||
messages: ReadonlyArray<Message>,
|
||||
strategy: NonNullable<CachePolicyObject["messages"]>,
|
||||
hint: CacheHint,
|
||||
): ReadonlyArray<Message> => {
|
||||
if (messages.length === 0) return messages
|
||||
if (strategy === "latest-user-message") return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint)
|
||||
if (strategy === "latest-assistant") return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint)
|
||||
const start = Math.max(0, messages.length - strategy.tail)
|
||||
let next = messages
|
||||
for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint)
|
||||
return next
|
||||
}
|
||||
|
||||
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
|
||||
if (!RESPECTS_INLINE_HINTS.has(request.model.route)) return request
|
||||
const policy = resolve(request.cache)
|
||||
if (!policy.tools && !policy.system && !policy.messages) return request
|
||||
|
||||
const hint = makeHint(policy.ttlSeconds)
|
||||
const tools = policy.tools ? markLastTool(request.tools, hint) : request.tools
|
||||
const system = policy.system ? markLastSystem(request.system, hint) : request.system
|
||||
const messages = policy.messages ? markMessages(request.messages, policy.messages, hint) : request.messages
|
||||
|
||||
if (tools === request.tools && system === request.system && messages === request.messages) return request
|
||||
return LLMRequest.update(request, { tools, system, messages })
|
||||
}
|
||||
@@ -5,10 +5,10 @@ import { Endpoint } from "../route/endpoint"
|
||||
import { Framing } from "../route/framing"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import {
|
||||
LLMEvent,
|
||||
Usage,
|
||||
type CacheHint,
|
||||
type FinishReason,
|
||||
type LLMEvent,
|
||||
type LLMRequest,
|
||||
type ProviderMetadata,
|
||||
type ToolCallPart,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type ToolResultPart,
|
||||
} from "../schema"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
import * as Cache from "./utils/cache"
|
||||
import { ToolStream } from "./utils/tool-stream"
|
||||
|
||||
const ADAPTER = "anthropic-messages"
|
||||
@@ -25,7 +26,10 @@ export const PATH = "/messages"
|
||||
// =============================================================================
|
||||
// Request Body Schema
|
||||
// =============================================================================
|
||||
const AnthropicCacheControl = Schema.Struct({ type: Schema.tag("ephemeral") })
|
||||
const AnthropicCacheControl = Schema.Struct({
|
||||
type: Schema.tag("ephemeral"),
|
||||
ttl: Schema.optional(Schema.Literals(["5m", "1h"])),
|
||||
})
|
||||
|
||||
const AnthropicTextBlock = Schema.Struct({
|
||||
type: Schema.tag("text"),
|
||||
@@ -193,8 +197,24 @@ const invalid = ProviderShared.invalidRequest
|
||||
// =============================================================================
|
||||
// Request Lowering
|
||||
// =============================================================================
|
||||
const cacheControl = (cache: CacheHint | undefined) =>
|
||||
cache?.type === "ephemeral" ? { type: "ephemeral" as const } : undefined
|
||||
// Anthropic accepts at most 4 explicit cache_control breakpoints per request,
|
||||
// across `tools`, `system`, and `messages`. Beyond the cap the API returns a
|
||||
// 400 — so the lowering layer counts emitted markers and silently drops any
|
||||
// that exceed it.
|
||||
const ANTHROPIC_BREAKPOINT_CAP = 4
|
||||
|
||||
const EPHEMERAL_5M = { type: "ephemeral" as const }
|
||||
const EPHEMERAL_1H = { type: "ephemeral" as const, ttl: "1h" as const }
|
||||
|
||||
const cacheControl = (breakpoints: Cache.Breakpoints, cache: CacheHint | undefined) => {
|
||||
if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined
|
||||
if (breakpoints.remaining <= 0) {
|
||||
breakpoints.dropped += 1
|
||||
return undefined
|
||||
}
|
||||
breakpoints.remaining -= 1
|
||||
return Cache.ttlBucket(cache.ttlSeconds) === "1h" ? EPHEMERAL_1H : EPHEMERAL_5M
|
||||
}
|
||||
|
||||
const anthropicMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ anthropic: metadata })
|
||||
|
||||
@@ -204,10 +224,11 @@ const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string |
|
||||
return typeof anthropic.signature === "string" ? anthropic.signature : undefined
|
||||
}
|
||||
|
||||
const lowerTool = (tool: ToolDefinition): AnthropicTool => ({
|
||||
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition): AnthropicTool => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
input_schema: tool.inputSchema,
|
||||
cache_control: cacheControl(breakpoints, tool.cache),
|
||||
})
|
||||
|
||||
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||
@@ -249,7 +270,10 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
|
||||
return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (request: LLMRequest) {
|
||||
const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
request: LLMRequest,
|
||||
breakpoints: Cache.Breakpoints,
|
||||
) {
|
||||
const messages: AnthropicMessage[] = []
|
||||
|
||||
for (const message of request.messages) {
|
||||
@@ -258,7 +282,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (re
|
||||
for (const part of message.content) {
|
||||
if (!ProviderShared.supportsContent(part, ["text"]))
|
||||
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text"])
|
||||
content.push({ type: "text", text: part.text, cache_control: cacheControl(part.cache) })
|
||||
content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
|
||||
}
|
||||
messages.push({ role: "user", content })
|
||||
continue
|
||||
@@ -268,7 +292,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (re
|
||||
const content: AnthropicAssistantBlock[] = []
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
content.push({ type: "text", text: part.text, cache_control: cacheControl(part.cache) })
|
||||
content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
|
||||
continue
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
@@ -304,6 +328,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (re
|
||||
tool_use_id: part.id,
|
||||
content: ProviderShared.toolResultText(part),
|
||||
is_error: part.result.type === "error" ? true : undefined,
|
||||
cache_control: cacheControl(breakpoints, part.cache),
|
||||
})
|
||||
}
|
||||
messages.push({ role: "user", content })
|
||||
@@ -330,18 +355,33 @@ const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (re
|
||||
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
||||
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
|
||||
const generation = request.generation
|
||||
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
|
||||
// messages. Tools live highest in the cache hierarchy, so when callers
|
||||
// over-mark we keep their tool hints and shed the message-tail ones first.
|
||||
const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
|
||||
const tools =
|
||||
request.tools.length === 0 || request.toolChoice?.type === "none"
|
||||
? undefined
|
||||
: request.tools.map((tool) => lowerTool(breakpoints, tool))
|
||||
const system =
|
||||
request.system.length === 0
|
||||
? undefined
|
||||
: request.system.map((part) => ({
|
||||
type: "text" as const,
|
||||
text: part.text,
|
||||
cache_control: cacheControl(breakpoints, part.cache),
|
||||
}))
|
||||
const messages = yield* lowerMessages(request, breakpoints)
|
||||
if (breakpoints.dropped > 0) {
|
||||
yield* Effect.logWarning(
|
||||
`Anthropic Messages: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${ANTHROPIC_BREAKPOINT_CAP} per request.`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
model: request.model.id,
|
||||
system:
|
||||
request.system.length === 0
|
||||
? undefined
|
||||
: request.system.map((part) => ({
|
||||
type: "text" as const,
|
||||
text: part.text,
|
||||
cache_control: cacheControl(part.cache),
|
||||
})),
|
||||
messages: yield* lowerMessages(request),
|
||||
tools: request.tools.length === 0 || request.toolChoice?.type === "none" ? undefined : request.tools.map(lowerTool),
|
||||
system,
|
||||
messages,
|
||||
tools,
|
||||
tool_choice: toolChoice,
|
||||
stream: true as const,
|
||||
max_tokens: generation?.maxTokens ?? request.model.limits.output ?? 4096,
|
||||
@@ -364,34 +404,56 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// Anthropic reports the non-overlapping breakdown natively — its
|
||||
// `input_tokens` is the *non-cached* count per the Messages API docs, with
|
||||
// cache reads and writes as separate fields. We sum them to derive the
|
||||
// inclusive `inputTokens` the rest of the contract expects. Extended
|
||||
// thinking tokens are *not* broken out by Anthropic — they're billed as
|
||||
// part of `output_tokens`, so `reasoningTokens` stays `undefined` and
|
||||
// `outputTokens` carries the combined total.
|
||||
const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const nonCached = usage.input_tokens
|
||||
const cacheRead = usage.cache_read_input_tokens ?? undefined
|
||||
const cacheWrite = usage.cache_creation_input_tokens ?? undefined
|
||||
const inputTokens = ProviderShared.sumTokens(nonCached, cacheRead, cacheWrite)
|
||||
return new Usage({
|
||||
inputTokens: usage.input_tokens,
|
||||
inputTokens,
|
||||
outputTokens: usage.output_tokens,
|
||||
cacheReadInputTokens: usage.cache_read_input_tokens ?? undefined,
|
||||
cacheWriteInputTokens: usage.cache_creation_input_tokens ?? undefined,
|
||||
totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, undefined),
|
||||
native: usage,
|
||||
nonCachedInputTokens: nonCached,
|
||||
cacheReadInputTokens: cacheRead,
|
||||
cacheWriteInputTokens: cacheWrite,
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined),
|
||||
providerMetadata: { anthropic: usage },
|
||||
})
|
||||
}
|
||||
|
||||
// Anthropic emits usage on `message_start` and again on `message_delta` — the
|
||||
// final delta carries the authoritative totals. Right-biased merge: each
|
||||
// field prefers `right` when defined, falls back to `left`. `totalTokens` is
|
||||
// recomputed from the merged input/output to stay consistent.
|
||||
// field prefers `right` when defined, falls back to `left`. `inputTokens` is
|
||||
// recomputed from the merged breakdown so the inclusive total stays
|
||||
// consistent with `nonCached + cacheRead + cacheWrite`.
|
||||
const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => {
|
||||
if (!left) return right
|
||||
if (!right) return left
|
||||
const inputTokens = right.inputTokens ?? left.inputTokens
|
||||
const nonCachedInputTokens = right.nonCachedInputTokens ?? left.nonCachedInputTokens
|
||||
const cacheReadInputTokens = right.cacheReadInputTokens ?? left.cacheReadInputTokens
|
||||
const cacheWriteInputTokens = right.cacheWriteInputTokens ?? left.cacheWriteInputTokens
|
||||
const inputTokens = ProviderShared.sumTokens(nonCachedInputTokens, cacheReadInputTokens, cacheWriteInputTokens)
|
||||
const outputTokens = right.outputTokens ?? left.outputTokens
|
||||
return new Usage({
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadInputTokens: right.cacheReadInputTokens ?? left.cacheReadInputTokens,
|
||||
cacheWriteInputTokens: right.cacheWriteInputTokens ?? left.cacheWriteInputTokens,
|
||||
nonCachedInputTokens,
|
||||
cacheReadInputTokens,
|
||||
cacheWriteInputTokens,
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
|
||||
native: { ...left.native, ...right.native },
|
||||
providerMetadata: {
|
||||
anthropic: {
|
||||
...(left.providerMetadata?.["anthropic"] ?? {}),
|
||||
...(right.providerMetadata?.["anthropic"] ?? {}),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -415,14 +477,13 @@ const serverToolResultEvent = (block: NonNullable<AnthropicEvent["content_block"
|
||||
? String((block.content as Record<string, unknown>).type)
|
||||
: ""
|
||||
const isError = errorPayload.endsWith("_tool_result_error")
|
||||
return {
|
||||
type: "tool-result",
|
||||
return LLMEvent.toolResult({
|
||||
id: block.tool_use_id ?? "",
|
||||
name: SERVER_TOOL_RESULT_NAMES[block.type],
|
||||
result: isError ? { type: "error", value: block.content } : { type: "json", value: block.content },
|
||||
providerExecuted: true,
|
||||
providerMetadata: anthropicMetadata({ blockType: block.type }),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
|
||||
@@ -453,18 +514,17 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
||||
}
|
||||
|
||||
if (block.type === "text" && block.text) {
|
||||
return [state, [{ type: "text-delta", text: block.text }]]
|
||||
return [state, [LLMEvent.textDelta({ id: `text-${event.index ?? 0}`, text: block.text })]]
|
||||
}
|
||||
|
||||
if (block.type === "thinking" && block.thinking) {
|
||||
return [
|
||||
state,
|
||||
[
|
||||
{
|
||||
type: "reasoning-delta",
|
||||
LLMEvent.reasoningDelta({
|
||||
id: `reasoning-${event.index ?? 0}`,
|
||||
text: block.thinking,
|
||||
...(block.signature ? { providerMetadata: anthropicMetadata({ signature: block.signature }) } : {}),
|
||||
},
|
||||
}),
|
||||
],
|
||||
]
|
||||
}
|
||||
@@ -480,17 +540,25 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
||||
const delta = event.delta
|
||||
|
||||
if (delta?.type === "text_delta" && delta.text) {
|
||||
return [state, [{ type: "text-delta", text: delta.text }]] satisfies StepResult
|
||||
return [state, [LLMEvent.textDelta({ id: `text-${event.index ?? 0}`, text: delta.text })]] satisfies StepResult
|
||||
}
|
||||
|
||||
if (delta?.type === "thinking_delta" && delta.thinking) {
|
||||
return [state, [{ type: "reasoning-delta", text: delta.thinking }]] satisfies StepResult
|
||||
return [
|
||||
state,
|
||||
[LLMEvent.reasoningDelta({ id: `reasoning-${event.index ?? 0}`, text: delta.thinking })],
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
if (delta?.type === "signature_delta" && delta.signature) {
|
||||
return [
|
||||
state,
|
||||
[{ type: "reasoning-delta", text: "", providerMetadata: anthropicMetadata({ signature: delta.signature }) }],
|
||||
[
|
||||
LLMEvent.reasoningEnd({
|
||||
id: `reasoning-${event.index ?? 0}`,
|
||||
providerMetadata: anthropicMetadata({ signature: delta.signature }),
|
||||
}),
|
||||
],
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
@@ -524,21 +592,20 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult =
|
||||
return [
|
||||
{ ...state, usage },
|
||||
[
|
||||
{
|
||||
type: "request-finish",
|
||||
LLMEvent.requestFinish({
|
||||
reason: mapFinishReason(event.delta?.stop_reason),
|
||||
usage,
|
||||
...(event.delta?.stop_sequence
|
||||
? { providerMetadata: anthropicMetadata({ stopSequence: event.delta.stop_sequence }) }
|
||||
: {}),
|
||||
},
|
||||
providerMetadata: event.delta?.stop_sequence
|
||||
? anthropicMetadata({ stopSequence: event.delta.stop_sequence })
|
||||
: undefined,
|
||||
}),
|
||||
],
|
||||
]
|
||||
}
|
||||
|
||||
const onError = (state: ParserState, event: AnthropicEvent): StepResult => [
|
||||
state,
|
||||
[{ type: "provider-error", message: event.error?.message ?? "Anthropic Messages stream error" }],
|
||||
[LLMEvent.providerError({ message: event.error?.message ?? "Anthropic Messages stream error" })],
|
||||
]
|
||||
|
||||
const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
|
||||
@@ -3,10 +3,10 @@ import { Route, type RouteModelInput } from "../route/client"
|
||||
import { Endpoint } from "../route/endpoint"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import {
|
||||
LLMEvent,
|
||||
Usage,
|
||||
type CacheHint,
|
||||
type FinishReason,
|
||||
type LLMEvent,
|
||||
type LLMRequest,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
@@ -108,7 +108,7 @@ type BedrockMessage = Schema.Schema.Type<typeof BedrockMessage>
|
||||
const BedrockSystemBlock = Schema.Union([BedrockTextBlock, BedrockCache.CachePointBlock])
|
||||
type BedrockSystemBlock = Schema.Schema.Type<typeof BedrockSystemBlock>
|
||||
|
||||
const BedrockTool = Schema.Struct({
|
||||
const BedrockToolSpec = Schema.Struct({
|
||||
toolSpec: Schema.Struct({
|
||||
name: Schema.String,
|
||||
description: Schema.String,
|
||||
@@ -117,6 +117,9 @@ const BedrockTool = Schema.Struct({
|
||||
}),
|
||||
}),
|
||||
})
|
||||
type BedrockToolSpec = Schema.Schema.Type<typeof BedrockToolSpec>
|
||||
|
||||
const BedrockTool = Schema.Union([BedrockToolSpec, BedrockCache.CachePointBlock])
|
||||
type BedrockTool = Schema.Schema.Type<typeof BedrockTool>
|
||||
|
||||
const BedrockToolChoice = Schema.Union([
|
||||
@@ -214,7 +217,7 @@ type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
|
||||
// =============================================================================
|
||||
// Request Lowering
|
||||
// =============================================================================
|
||||
const lowerTool = (tool: ToolDefinition): BedrockTool => ({
|
||||
const lowerToolSpec = (tool: ToolDefinition): BedrockToolSpec => ({
|
||||
toolSpec: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
@@ -222,11 +225,22 @@ const lowerTool = (tool: ToolDefinition): BedrockTool => ({
|
||||
},
|
||||
})
|
||||
|
||||
const lowerTools = (breakpoints: BedrockCache.Breakpoints, tools: ReadonlyArray<ToolDefinition>): BedrockTool[] => {
|
||||
const result: BedrockTool[] = []
|
||||
for (const tool of tools) {
|
||||
result.push(lowerToolSpec(tool))
|
||||
const cachePoint = BedrockCache.block(breakpoints, tool.cache)
|
||||
if (cachePoint) result.push(cachePoint)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const textWithCache = (
|
||||
breakpoints: BedrockCache.Breakpoints,
|
||||
text: string,
|
||||
cache: CacheHint | undefined,
|
||||
): Array<BedrockTextBlock | BedrockCache.CachePointBlock> => {
|
||||
const cachePoint = BedrockCache.block(cache)
|
||||
const cachePoint = BedrockCache.block(breakpoints, cache)
|
||||
return cachePoint ? [{ text }, cachePoint] : [{ text }]
|
||||
}
|
||||
|
||||
@@ -257,7 +271,10 @@ const lowerToolResult = (part: ToolResultPart): BedrockToolResultBlock => ({
|
||||
},
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (request: LLMRequest) {
|
||||
const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
request: LLMRequest,
|
||||
breakpoints: BedrockCache.Breakpoints,
|
||||
) {
|
||||
const messages: BedrockMessage[] = []
|
||||
|
||||
for (const message of request.messages) {
|
||||
@@ -267,7 +284,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (requ
|
||||
if (!ProviderShared.supportsContent(part, ["text", "media"]))
|
||||
return yield* ProviderShared.unsupportedContent("Bedrock Converse", "user", ["text", "media"])
|
||||
if (part.type === "text") {
|
||||
content.push(...textWithCache(part.text, part.cache))
|
||||
content.push(...textWithCache(breakpoints, part.text, part.cache))
|
||||
continue
|
||||
}
|
||||
if (part.type === "media") {
|
||||
@@ -289,7 +306,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (requ
|
||||
"tool-call",
|
||||
])
|
||||
if (part.type === "text") {
|
||||
content.push(...textWithCache(part.text, part.cache))
|
||||
content.push(...textWithCache(breakpoints, part.text, part.cache))
|
||||
continue
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
@@ -309,11 +326,13 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (requ
|
||||
continue
|
||||
}
|
||||
|
||||
const content: BedrockToolResultBlock[] = []
|
||||
const content: BedrockUserBlock[] = []
|
||||
for (const part of message.content) {
|
||||
if (!ProviderShared.supportsContent(part, ["tool-result"]))
|
||||
return yield* ProviderShared.unsupportedContent("Bedrock Converse", "tool", ["tool-result"])
|
||||
content.push(lowerToolResult(part))
|
||||
const cachePoint = BedrockCache.block(breakpoints, part.cache)
|
||||
if (cachePoint) content.push(cachePoint)
|
||||
}
|
||||
messages.push({ role: "user", content })
|
||||
}
|
||||
@@ -323,16 +342,32 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (requ
|
||||
|
||||
// System prompts share the cache-point convention: emit the text block, then
|
||||
// optionally a positional `cachePoint` marker.
|
||||
const lowerSystem = (system: ReadonlyArray<LLMRequest["system"][number]>): BedrockSystemBlock[] =>
|
||||
system.flatMap((part) => textWithCache(part.text, part.cache))
|
||||
const lowerSystem = (
|
||||
breakpoints: BedrockCache.Breakpoints,
|
||||
system: ReadonlyArray<LLMRequest["system"][number]>,
|
||||
): BedrockSystemBlock[] => system.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
|
||||
|
||||
const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) {
|
||||
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
|
||||
const generation = request.generation
|
||||
// Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in
|
||||
// tools → system → messages order to favour the highest-impact prefixes.
|
||||
const breakpoints = BedrockCache.breakpoints()
|
||||
const toolConfig =
|
||||
request.tools.length > 0 && request.toolChoice?.type !== "none"
|
||||
? { tools: lowerTools(breakpoints, request.tools), toolChoice }
|
||||
: undefined
|
||||
const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system)
|
||||
const messages = yield* lowerMessages(request, breakpoints)
|
||||
if (breakpoints.dropped > 0) {
|
||||
yield* Effect.logWarning(
|
||||
`Bedrock Converse: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${BedrockCache.BEDROCK_BREAKPOINT_CAP} per request.`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
modelId: request.model.id,
|
||||
messages: yield* lowerMessages(request),
|
||||
system: request.system.length === 0 ? undefined : lowerSystem(request.system),
|
||||
messages,
|
||||
system,
|
||||
inferenceConfig:
|
||||
generation?.maxTokens === undefined &&
|
||||
generation?.temperature === undefined &&
|
||||
@@ -345,10 +380,7 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
|
||||
topP: generation?.topP,
|
||||
stopSequences: generation?.stop,
|
||||
},
|
||||
toolConfig:
|
||||
request.tools.length > 0 && request.toolChoice?.type !== "none"
|
||||
? { tools: request.tools.map(lowerTool), toolChoice }
|
||||
: undefined,
|
||||
toolConfig,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -363,15 +395,22 @@ const mapFinishReason = (reason: string): FinishReason => {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// AWS Bedrock Converse reports `inputTokens` (inclusive total) with
|
||||
// `cacheReadInputTokens` and `cacheWriteInputTokens` as subsets. Pass
|
||||
// the total through and derive the non-cached breakdown. Bedrock does
|
||||
// not break reasoning out of `outputTokens` for any current model.
|
||||
const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const cacheTotal = (usage.cacheReadInputTokens ?? 0) + (usage.cacheWriteInputTokens ?? 0)
|
||||
const nonCached = ProviderShared.subtractTokens(usage.inputTokens, cacheTotal)
|
||||
return new Usage({
|
||||
inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
totalTokens: ProviderShared.totalTokens(usage.inputTokens, usage.outputTokens, usage.totalTokens),
|
||||
nonCachedInputTokens: nonCached,
|
||||
cacheReadInputTokens: usage.cacheReadInputTokens,
|
||||
cacheWriteInputTokens: usage.cacheWriteInputTokens,
|
||||
native: usage,
|
||||
totalTokens: ProviderShared.totalTokens(usage.inputTokens, usage.outputTokens, usage.totalTokens),
|
||||
providerMetadata: { bedrock: usage },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -400,13 +439,26 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
}
|
||||
|
||||
if (event.contentBlockDelta?.delta?.text) {
|
||||
return [state, [{ type: "text-delta" as const, text: event.contentBlockDelta.delta.text }]] as const
|
||||
return [
|
||||
state,
|
||||
[
|
||||
LLMEvent.textDelta({
|
||||
id: `text-${event.contentBlockDelta.contentBlockIndex}`,
|
||||
text: event.contentBlockDelta.delta.text,
|
||||
}),
|
||||
],
|
||||
] as const
|
||||
}
|
||||
|
||||
if (event.contentBlockDelta?.delta?.reasoningContent?.text) {
|
||||
return [
|
||||
state,
|
||||
[{ type: "reasoning-delta" as const, text: event.contentBlockDelta.delta.reasoningContent.text }],
|
||||
[
|
||||
LLMEvent.reasoningDelta({
|
||||
id: `reasoning-${event.contentBlockDelta.contentBlockIndex}`,
|
||||
text: event.contentBlockDelta.delta.reasoningContent.text,
|
||||
}),
|
||||
],
|
||||
] as const
|
||||
}
|
||||
|
||||
@@ -449,16 +501,13 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
event.modelStreamErrorException?.message ??
|
||||
event.serviceUnavailableException?.message ??
|
||||
"Bedrock Converse stream error"
|
||||
return [state, [{ type: "provider-error" as const, message, retryable: true }]] as const
|
||||
return [state, [LLMEvent.providerError({ message, retryable: true })]] as const
|
||||
}
|
||||
|
||||
if (event.validationException || event.throttlingException) {
|
||||
const message =
|
||||
event.validationException?.message ?? event.throttlingException?.message ?? "Bedrock Converse error"
|
||||
return [
|
||||
state,
|
||||
[{ type: "provider-error" as const, message, retryable: event.throttlingException !== undefined }],
|
||||
] as const
|
||||
return [state, [LLMEvent.providerError({ message, retryable: event.throttlingException !== undefined })]] as const
|
||||
}
|
||||
|
||||
return [state, []] as const
|
||||
@@ -468,7 +517,7 @@ const framing = BedrockEventStream.framing(ADAPTER)
|
||||
|
||||
const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
||||
state.pendingFinish
|
||||
? [{ type: "request-finish", reason: state.pendingFinish.reason, usage: state.pendingFinish.usage }]
|
||||
? [LLMEvent.requestFinish({ reason: state.pendingFinish.reason, usage: state.pendingFinish.usage })]
|
||||
: []
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -5,9 +5,9 @@ import { Endpoint } from "../route/endpoint"
|
||||
import { Framing } from "../route/framing"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import {
|
||||
LLMEvent,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type LLMEvent,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type TextPart,
|
||||
@@ -281,15 +281,28 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
// =============================================================================
|
||||
// Stream Parsing
|
||||
// =============================================================================
|
||||
// Gemini reports `promptTokenCount` (inclusive total) with a
|
||||
// `cachedContentTokenCount` subset. `candidatesTokenCount` is *exclusive*
|
||||
// of `thoughtsTokenCount` — visible-only, not a total — so we sum the two
|
||||
// to produce the inclusive `outputTokens` the rest of the contract expects.
|
||||
const mapUsage = (usage: GeminiUsage | undefined) => {
|
||||
if (!usage) return undefined
|
||||
const cached = usage.cachedContentTokenCount
|
||||
const nonCached = ProviderShared.subtractTokens(usage.promptTokenCount, cached)
|
||||
// `candidatesTokenCount` is visible-only; sum with thoughts to produce the
|
||||
// inclusive `outputTokens` the contract expects. Only compute the total
|
||||
// when the visible component is reported — otherwise we'd fabricate an
|
||||
// inclusive number from a partial breakdown.
|
||||
const outputTokens =
|
||||
usage.candidatesTokenCount !== undefined ? usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0) : undefined
|
||||
return new Usage({
|
||||
inputTokens: usage.promptTokenCount,
|
||||
outputTokens: usage.candidatesTokenCount,
|
||||
outputTokens,
|
||||
nonCachedInputTokens: nonCached,
|
||||
cacheReadInputTokens: cached,
|
||||
reasoningTokens: usage.thoughtsTokenCount,
|
||||
cacheReadInputTokens: usage.cachedContentTokenCount,
|
||||
totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, usage.candidatesTokenCount, usage.totalTokenCount),
|
||||
native: usage,
|
||||
totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount),
|
||||
providerMetadata: { google: usage },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -311,7 +324,7 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
|
||||
|
||||
const finish = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
||||
state.finishReason || state.usage
|
||||
? [{ type: "request-finish", reason: mapFinishReason(state.finishReason, state.hasToolCalls), usage: state.usage }]
|
||||
? [LLMEvent.requestFinish({ reason: mapFinishReason(state.finishReason, state.hasToolCalls), usage: state.usage })]
|
||||
: []
|
||||
|
||||
const step = (state: ParserState, event: GeminiEvent) => {
|
||||
@@ -332,14 +345,18 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
|
||||
for (const part of candidate.content.parts) {
|
||||
if ("text" in part && part.text.length > 0) {
|
||||
events.push({ type: part.thought ? "reasoning-delta" : "text-delta", text: part.text })
|
||||
events.push(
|
||||
part.thought
|
||||
? LLMEvent.reasoningDelta({ id: "reasoning-0", text: part.text })
|
||||
: LLMEvent.textDelta({ id: "text-0", text: part.text }),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if ("functionCall" in part) {
|
||||
const input = part.functionCall.args
|
||||
const id = `tool_${nextToolCallId++}`
|
||||
events.push({ type: "tool-call", id, name: part.functionCall.name, input })
|
||||
events.push(LLMEvent.toolCall({ id, name: part.functionCall.name, input }))
|
||||
hasToolCalls = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ import { Framing } from "../route/framing"
|
||||
import { HttpTransport } from "../route/transport"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import {
|
||||
LLMEvent,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type LLMEvent,
|
||||
type LLMRequest,
|
||||
type TextPart,
|
||||
type ToolCallPart,
|
||||
@@ -290,15 +290,24 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// OpenAI Chat reports `prompt_tokens` (inclusive total) with a
|
||||
// `cached_tokens` subset, and `completion_tokens` (inclusive total) with
|
||||
// a `reasoning_tokens` subset. We pass the inclusive totals through and
|
||||
// derive the non-cached breakdown so the `LLM.Usage` contract is
|
||||
// satisfied on both sides.
|
||||
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const cached = usage.prompt_tokens_details?.cached_tokens
|
||||
const reasoning = usage.completion_tokens_details?.reasoning_tokens
|
||||
const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, cached)
|
||||
return new Usage({
|
||||
inputTokens: usage.prompt_tokens,
|
||||
outputTokens: usage.completion_tokens,
|
||||
reasoningTokens: usage.completion_tokens_details?.reasoning_tokens,
|
||||
cacheReadInputTokens: usage.prompt_tokens_details?.cached_tokens,
|
||||
nonCachedInputTokens: nonCached,
|
||||
cacheReadInputTokens: cached,
|
||||
reasoningTokens: reasoning,
|
||||
totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens),
|
||||
native: usage,
|
||||
providerMetadata: { openai: usage },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -312,7 +321,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
const toolDeltas = delta?.tool_calls ?? []
|
||||
let tools = state.tools
|
||||
|
||||
if (delta?.content) events.push({ type: "text-delta", text: delta.content })
|
||||
if (delta?.content) events.push(LLMEvent.textDelta({ id: "text-0", text: delta.content }))
|
||||
|
||||
for (const tool of toolDeltas) {
|
||||
const result = ToolStream.appendOrStart(
|
||||
@@ -348,10 +357,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
const hasToolCalls = state.toolCallEvents.length > 0
|
||||
const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
|
||||
return [
|
||||
...state.toolCallEvents,
|
||||
...(reason ? ([{ type: "request-finish", reason, usage: state.usage }] satisfies ReadonlyArray<LLMEvent>) : []),
|
||||
]
|
||||
return [...state.toolCallEvents, ...(reason ? [LLMEvent.requestFinish({ reason, usage: state.usage })] : [])]
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -6,9 +6,9 @@ import { Framing } from "../route/framing"
|
||||
import { HttpTransport, WebSocketTransport } from "../route/transport"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import {
|
||||
LLMEvent,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type LLMEvent,
|
||||
type LLMRequest,
|
||||
type ProviderMetadata,
|
||||
type TextPart,
|
||||
@@ -276,15 +276,23 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
// =============================================================================
|
||||
// Stream Parsing
|
||||
// =============================================================================
|
||||
// OpenAI Responses reports `input_tokens` (inclusive total) with a
|
||||
// `cached_tokens` subset, and `output_tokens` (inclusive total) with a
|
||||
// `reasoning_tokens` subset. Pass the totals through and derive the
|
||||
// non-cached breakdown.
|
||||
const mapUsage = (usage: OpenAIResponsesUsage | null | undefined) => {
|
||||
if (!usage) return undefined
|
||||
const cached = usage.input_tokens_details?.cached_tokens
|
||||
const reasoning = usage.output_tokens_details?.reasoning_tokens
|
||||
const nonCached = ProviderShared.subtractTokens(usage.input_tokens, cached)
|
||||
return new Usage({
|
||||
inputTokens: usage.input_tokens,
|
||||
outputTokens: usage.output_tokens,
|
||||
reasoningTokens: usage.output_tokens_details?.reasoning_tokens,
|
||||
cacheReadInputTokens: usage.input_tokens_details?.cached_tokens,
|
||||
nonCachedInputTokens: nonCached,
|
||||
cacheReadInputTokens: cached,
|
||||
reasoningTokens: reasoning,
|
||||
totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, usage.total_tokens),
|
||||
native: usage,
|
||||
providerMetadata: { openai: usage },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -348,22 +356,20 @@ const hostedToolEvents = (
|
||||
const tool = HOSTED_TOOLS[item.type]
|
||||
const providerMetadata = openaiMetadata({ itemId: item.id })
|
||||
return [
|
||||
{
|
||||
type: "tool-call",
|
||||
LLMEvent.toolCall({
|
||||
id: item.id,
|
||||
name: tool.name,
|
||||
input: tool.input(item),
|
||||
providerExecuted: true,
|
||||
providerMetadata,
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
}),
|
||||
LLMEvent.toolResult({
|
||||
id: item.id,
|
||||
name: tool.name,
|
||||
result: hostedToolResult(item),
|
||||
providerExecuted: true,
|
||||
providerMetadata,
|
||||
},
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -379,17 +385,7 @@ const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "re
|
||||
|
||||
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
if (!event.delta) return [state, NO_EVENTS]
|
||||
return [
|
||||
state,
|
||||
[
|
||||
{
|
||||
type: "text-delta",
|
||||
id: event.item_id,
|
||||
text: event.delta,
|
||||
...(event.item_id ? { providerMetadata: openaiMetadata({ itemId: event.item_id }) } : {}),
|
||||
},
|
||||
],
|
||||
]
|
||||
return [state, [LLMEvent.textDelta({ id: event.item_id ?? "text-0", text: event.delta })]]
|
||||
}
|
||||
|
||||
const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
@@ -458,30 +454,28 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
|
||||
const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
||||
state,
|
||||
[
|
||||
{
|
||||
type: "request-finish",
|
||||
LLMEvent.requestFinish({
|
||||
reason: mapFinishReason(event, state.hasFunctionCall),
|
||||
usage: mapUsage(event.response?.usage),
|
||||
...(event.response?.id || event.response?.service_tier
|
||||
? {
|
||||
providerMetadata: openaiMetadata({
|
||||
providerMetadata:
|
||||
event.response?.id || event.response?.service_tier
|
||||
? openaiMetadata({
|
||||
responseId: event.response.id,
|
||||
serviceTier: event.response.service_tier,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
}),
|
||||
],
|
||||
]
|
||||
|
||||
const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
||||
state,
|
||||
[{ type: "provider-error", message: event.message ?? event.code ?? "OpenAI Responses response failed" }],
|
||||
[LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses response failed" })],
|
||||
]
|
||||
|
||||
const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
||||
state,
|
||||
[{ type: "provider-error", message: event.message ?? event.code ?? "OpenAI Responses stream error" }],
|
||||
[LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses stream error" })],
|
||||
]
|
||||
|
||||
const step = (state: ParserState, event: OpenAIResponsesEvent) => {
|
||||
|
||||
@@ -42,6 +42,13 @@ export interface ToolAccumulator {
|
||||
* supplied total; otherwise falls back to `inputTokens + outputTokens` only
|
||||
* when at least one is defined. Returns `undefined` when neither input nor
|
||||
* output is known so routes don't publish a misleading `0`.
|
||||
*
|
||||
* Under the additive `LLM.Usage` contract, `inputTokens` and `outputTokens`
|
||||
* are the non-cached input and visible output only. The provider-supplied
|
||||
* `total` is the source of truth when present; the computed fallback
|
||||
* under-counts cache and reasoning by design and exists mainly so
|
||||
* Anthropic-style providers (which don't surface a total) still get a
|
||||
* sensible aggregate on the input + output axes.
|
||||
*/
|
||||
export const totalTokens = (
|
||||
inputTokens: number | undefined,
|
||||
@@ -53,6 +60,35 @@ export const totalTokens = (
|
||||
return (inputTokens ?? 0) + (outputTokens ?? 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtract `subtrahend` from `total`, clamping to zero if the provider
|
||||
* reports a non-sensical breakdown (e.g. `cached_tokens > prompt_tokens`).
|
||||
* Used by protocol mappers when deriving a non-overlapping breakdown field
|
||||
* from a provider's inclusive total — `nonCachedInputTokens` from
|
||||
* `inputTokens - cacheReadInputTokens - cacheWriteInputTokens`.
|
||||
*
|
||||
* If `total` is `undefined`, returns `undefined` (we don't fabricate
|
||||
* counts). If `subtrahend` is `undefined`, returns `total` unchanged. The
|
||||
* provider-native breakdown stays available on `Usage.native` for debugging.
|
||||
*/
|
||||
export const subtractTokens = (total: number | undefined, subtrahend: number | undefined): number | undefined => {
|
||||
if (total === undefined) return undefined
|
||||
if (subtrahend === undefined) return total
|
||||
return Math.max(0, total - subtrahend)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum a list of optional token counts, returning `undefined` only when
|
||||
* every value is `undefined` (so we don't fabricate a `0`). Used by
|
||||
* protocol mappers to derive the inclusive `inputTokens` total from a
|
||||
* provider that natively reports a non-overlapping breakdown
|
||||
* (e.g. Anthropic, whose `input_tokens` is already non-cached only).
|
||||
*/
|
||||
export const sumTokens = (...values: ReadonlyArray<number | undefined>): number | undefined => {
|
||||
if (values.every((value) => value === undefined)) return undefined
|
||||
return values.reduce<number>((acc, value) => acc + (value ?? 0), 0)
|
||||
}
|
||||
|
||||
export const eventError = (route: string, message: string, raw?: string) =>
|
||||
new LLMError({
|
||||
module: "ProviderShared",
|
||||
|
||||
@@ -1,20 +1,37 @@
|
||||
import { Schema } from "effect"
|
||||
import type { CacheHint } from "../../schema"
|
||||
import { newBreakpoints, ttlBucket, type Breakpoints } from "./cache"
|
||||
|
||||
// Bedrock cache markers are positional: emit a `cachePoint` block immediately
|
||||
// after the content the caller wants treated as a cacheable prefix.
|
||||
// after the content the caller wants treated as a cacheable prefix. Bedrock
|
||||
// accepts optional `ttl: "5m" | "1h"` on cachePoint, mirroring Anthropic.
|
||||
export const CachePointBlock = Schema.Struct({
|
||||
cachePoint: Schema.Struct({ type: Schema.tag("default") }),
|
||||
cachePoint: Schema.Struct({
|
||||
type: Schema.tag("default"),
|
||||
ttl: Schema.optional(Schema.Literals(["5m", "1h"])),
|
||||
}),
|
||||
})
|
||||
export type CachePointBlock = Schema.Schema.Type<typeof CachePointBlock>
|
||||
|
||||
// Bedrock recently added optional `ttl: "5m" | "1h"` on cachePoint. Map
|
||||
// `CacheHint.ttlSeconds` here once a recorded cassette validates the wire shape.
|
||||
const DEFAULT: CachePointBlock = { cachePoint: { type: "default" } }
|
||||
// Bedrock-Claude enforces the same 4-breakpoint cap as the Anthropic Messages
|
||||
// API. Callers pass a shared counter through every `block()` call site so the
|
||||
// budget is respected across `system`, `messages`, and `tools`.
|
||||
export const BEDROCK_BREAKPOINT_CAP = 4
|
||||
|
||||
export const block = (cache: CacheHint | undefined): CachePointBlock | undefined => {
|
||||
export type { Breakpoints } from "./cache"
|
||||
export const breakpoints = () => newBreakpoints(BEDROCK_BREAKPOINT_CAP)
|
||||
|
||||
const DEFAULT_5M: CachePointBlock = { cachePoint: { type: "default" } }
|
||||
const DEFAULT_1H: CachePointBlock = { cachePoint: { type: "default", ttl: "1h" } }
|
||||
|
||||
export const block = (breakpoints: Breakpoints, cache: CacheHint | undefined): CachePointBlock | undefined => {
|
||||
if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined
|
||||
return DEFAULT
|
||||
if (breakpoints.remaining <= 0) {
|
||||
breakpoints.dropped += 1
|
||||
return undefined
|
||||
}
|
||||
breakpoints.remaining -= 1
|
||||
return ttlBucket(cache.ttlSeconds) === "1h" ? DEFAULT_1H : DEFAULT_5M
|
||||
}
|
||||
|
||||
export * as BedrockCache from "./bedrock-cache"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Shared helpers for provider cache-marker lowering. Anthropic and Bedrock
|
||||
// both enforce a 4-breakpoint cap per request and accept the same `5m`/`1h`
|
||||
// TTL buckets, so the counter and TTL mapping live here.
|
||||
|
||||
export interface Breakpoints {
|
||||
remaining: number
|
||||
dropped: number
|
||||
}
|
||||
|
||||
export const newBreakpoints = (cap: number): Breakpoints => ({ remaining: cap, dropped: 0 })
|
||||
|
||||
// Returns `"1h"` for any `ttlSeconds >= 3600`, otherwise `undefined` (the
|
||||
// provider default 5m). Anthropic & Bedrock both treat anything shorter than
|
||||
// an hour as 5m.
|
||||
export const ttlBucket = (ttlSeconds: number | undefined): "1h" | undefined =>
|
||||
ttlSeconds !== undefined && ttlSeconds >= 3600 ? "1h" : undefined
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { LLMError, type ProviderMetadata, type ToolCall, type ToolInputDelta } from "../../schema"
|
||||
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputDelta } from "../../schema"
|
||||
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
|
||||
|
||||
type StreamKey = string | number
|
||||
@@ -49,34 +49,24 @@ const withoutTool = <K extends StreamKey>(tools: State<K>, key: K): State<K> =>
|
||||
return next
|
||||
}
|
||||
|
||||
const inputDelta = (tool: PendingTool, text: string): ToolInputDelta => ({
|
||||
type: "tool-input-delta",
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
text,
|
||||
...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}),
|
||||
})
|
||||
const inputDelta = (tool: PendingTool, text: string): ToolInputDelta =>
|
||||
LLMEvent.toolInputDelta({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
text,
|
||||
})
|
||||
|
||||
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) =>
|
||||
parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe(
|
||||
Effect.map(
|
||||
(input): ToolCall =>
|
||||
tool.providerExecuted
|
||||
? {
|
||||
type: "tool-call",
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
input,
|
||||
providerExecuted: true,
|
||||
...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}),
|
||||
}
|
||||
: {
|
||||
type: "tool-call",
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
input,
|
||||
...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}),
|
||||
},
|
||||
LLMEvent.toolCall({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
input,
|
||||
providerExecuted: tool.providerExecuted ? true : undefined,
|
||||
providerMetadata: tool.providerMetadata,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { Transport, TransportRuntime } from "./transport"
|
||||
import { WebSocketExecutor } from "./transport"
|
||||
import type { Service as WebSocketExecutorService } from "./transport/websocket"
|
||||
import type { Protocol } from "./protocol"
|
||||
import { applyCachePolicy } from "../cache-policy"
|
||||
import * as ProviderShared from "../protocols/shared"
|
||||
import * as ToolRuntime from "../tool-runtime"
|
||||
import type { Tools } from "../tool"
|
||||
@@ -400,7 +401,7 @@ export function make<Body, Prepared, Frame, Event, State>(
|
||||
// validated provider body plus transport-private prepared data, but does not
|
||||
// execute transport.
|
||||
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
|
||||
const resolved = resolveRequestOptions(request)
|
||||
const resolved = applyCachePolicy(resolveRequestOptions(request))
|
||||
const route = registeredRoute(resolved.model.route)
|
||||
if (!route) return yield* noRoute(resolved.model)
|
||||
|
||||
|
||||
@@ -1,73 +1,155 @@
|
||||
import { Schema } from "effect"
|
||||
import { FinishReason, ProtocolID, ProviderMetadata, RouteID } from "./ids"
|
||||
import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, ResponseID, RouteID, ToolCallID } from "./ids"
|
||||
import { ModelRef } from "./options"
|
||||
import { ToolResultValue } from "./messages"
|
||||
|
||||
/**
|
||||
* Token usage reported by an LLM provider.
|
||||
*
|
||||
* **Inclusive totals** (match AI SDK / OpenAI / LangChain convention — a
|
||||
* reader from any of those ecosystems sees the number they expect):
|
||||
*
|
||||
* - `inputTokens` — total prompt tokens, *including* cached reads/writes.
|
||||
* - `outputTokens` — total output tokens, *including* reasoning.
|
||||
* - `totalTokens` — provider-supplied total, or `inputTokens + outputTokens`.
|
||||
*
|
||||
* **Non-overlapping breakdown** (every field is independently meaningful;
|
||||
* consumers never have to subtract):
|
||||
*
|
||||
* - `nonCachedInputTokens` — the "fresh" portion of the prompt.
|
||||
* - `cacheReadInputTokens` — input tokens served from cache.
|
||||
* - `cacheWriteInputTokens` — input tokens written to cache.
|
||||
* - `reasoningTokens` — subset of `outputTokens` spent on hidden reasoning.
|
||||
*
|
||||
* **Invariant**: `nonCachedInputTokens + cacheReadInputTokens +
|
||||
* cacheWriteInputTokens = inputTokens`, and `reasoningTokens ≤ outputTokens`.
|
||||
* Each protocol mapper computes whichever side it doesn't get natively,
|
||||
* with `Math.max(0, …)` clamping for defense against provider bugs. Because
|
||||
* every breakdown field is stored independently, downstream consumers can
|
||||
* read whatever they need (cost-by-category, context-pressure, AI-SDK-style
|
||||
* inclusive total) without ever subtracting — eliminating the underflow
|
||||
* class of bug where a clamped difference would silently store the wrong
|
||||
* value.
|
||||
*
|
||||
* **Semantics by provider**:
|
||||
*
|
||||
* - OpenAI Chat / Responses / Gemini / Bedrock: provider reports inclusive
|
||||
* `inputTokens` and an inclusive `outputTokens`; mapper subtracts to
|
||||
* derive the breakdown.
|
||||
* - Anthropic: provider reports the breakdown natively (`input_tokens` is
|
||||
* non-cached only); mapper sums to derive the inclusive `inputTokens`.
|
||||
* Anthropic does *not* break extended-thinking out of `output_tokens`, so
|
||||
* `reasoningTokens` is `undefined` and `outputTokens` carries the
|
||||
* combined total — a documented limitation of the Anthropic API.
|
||||
*
|
||||
* `providerMetadata` always carries the provider's raw usage payload —
|
||||
* keyed by provider name (`{ openai: ... }`, `{ anthropic: ... }`, etc.)
|
||||
* — for fields we don't normalize and for billing-level audit trails.
|
||||
* Matches the same escape-hatch field on `LLMEvent`.
|
||||
*/
|
||||
export class Usage extends Schema.Class<Usage>("LLM.Usage")({
|
||||
inputTokens: Schema.optional(Schema.Number),
|
||||
outputTokens: Schema.optional(Schema.Number),
|
||||
reasoningTokens: Schema.optional(Schema.Number),
|
||||
nonCachedInputTokens: Schema.optional(Schema.Number),
|
||||
cacheReadInputTokens: Schema.optional(Schema.Number),
|
||||
cacheWriteInputTokens: Schema.optional(Schema.Number),
|
||||
reasoningTokens: Schema.optional(Schema.Number),
|
||||
totalTokens: Schema.optional(Schema.Number),
|
||||
native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}) {}
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}) {
|
||||
/**
|
||||
* Visible output tokens — `outputTokens` minus `reasoningTokens`, clamped
|
||||
* to zero. The one place subtraction happens in this contract; the clamp
|
||||
* means a provider reporting `reasoningTokens > outputTokens` produces a
|
||||
* harmless zero rather than a negative that crashes downstream schemas.
|
||||
*/
|
||||
get visibleOutputTokens() {
|
||||
return Math.max(0, (this.outputTokens ?? 0) - (this.reasoningTokens ?? 0))
|
||||
}
|
||||
}
|
||||
|
||||
export const RequestStart = Schema.Struct({
|
||||
type: Schema.Literal("request-start"),
|
||||
id: Schema.String,
|
||||
type: Schema.tag("request-start"),
|
||||
id: ResponseID,
|
||||
model: ModelRef,
|
||||
}).annotate({ identifier: "LLM.Event.RequestStart" })
|
||||
export type RequestStart = Schema.Schema.Type<typeof RequestStart>
|
||||
|
||||
export const StepStart = Schema.Struct({
|
||||
type: Schema.Literal("step-start"),
|
||||
type: Schema.tag("step-start"),
|
||||
index: Schema.Number,
|
||||
}).annotate({ identifier: "LLM.Event.StepStart" })
|
||||
export type StepStart = Schema.Schema.Type<typeof StepStart>
|
||||
|
||||
export const TextStart = Schema.Struct({
|
||||
type: Schema.Literal("text-start"),
|
||||
id: Schema.String,
|
||||
type: Schema.tag("text-start"),
|
||||
id: ContentBlockID,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.TextStart" })
|
||||
export type TextStart = Schema.Schema.Type<typeof TextStart>
|
||||
|
||||
export const TextDelta = Schema.Struct({
|
||||
type: Schema.Literal("text-delta"),
|
||||
id: Schema.optional(Schema.String),
|
||||
type: Schema.tag("text-delta"),
|
||||
id: ContentBlockID,
|
||||
text: Schema.String,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.TextDelta" })
|
||||
export type TextDelta = Schema.Schema.Type<typeof TextDelta>
|
||||
|
||||
export const TextEnd = Schema.Struct({
|
||||
type: Schema.Literal("text-end"),
|
||||
id: Schema.String,
|
||||
type: Schema.tag("text-end"),
|
||||
id: ContentBlockID,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.TextEnd" })
|
||||
export type TextEnd = Schema.Schema.Type<typeof TextEnd>
|
||||
|
||||
export const ReasoningDelta = Schema.Struct({
|
||||
type: Schema.Literal("reasoning-delta"),
|
||||
id: Schema.optional(Schema.String),
|
||||
text: Schema.String,
|
||||
export const ReasoningStart = Schema.Struct({
|
||||
type: Schema.tag("reasoning-start"),
|
||||
id: ContentBlockID,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ReasoningStart" })
|
||||
export type ReasoningStart = Schema.Schema.Type<typeof ReasoningStart>
|
||||
|
||||
export const ReasoningDelta = Schema.Struct({
|
||||
type: Schema.tag("reasoning-delta"),
|
||||
id: ContentBlockID,
|
||||
text: Schema.String,
|
||||
}).annotate({ identifier: "LLM.Event.ReasoningDelta" })
|
||||
export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
|
||||
|
||||
export const ReasoningEnd = Schema.Struct({
|
||||
type: Schema.tag("reasoning-end"),
|
||||
id: ContentBlockID,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ReasoningEnd" })
|
||||
export type ReasoningEnd = Schema.Schema.Type<typeof ReasoningEnd>
|
||||
|
||||
export const ToolInputStart = Schema.Struct({
|
||||
type: Schema.tag("tool-input-start"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
|
||||
export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
|
||||
|
||||
export const ToolInputDelta = Schema.Struct({
|
||||
type: Schema.Literal("tool-input-delta"),
|
||||
id: Schema.String,
|
||||
type: Schema.tag("tool-input-delta"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
text: Schema.String,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ToolInputDelta" })
|
||||
export type ToolInputDelta = Schema.Schema.Type<typeof ToolInputDelta>
|
||||
|
||||
export const ToolInputEnd = Schema.Struct({
|
||||
type: Schema.tag("tool-input-end"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ToolInputEnd" })
|
||||
export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
|
||||
|
||||
export const ToolCall = Schema.Struct({
|
||||
type: Schema.Literal("tool-call"),
|
||||
id: Schema.String,
|
||||
type: Schema.tag("tool-call"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
input: Schema.Unknown,
|
||||
providerExecuted: Schema.optional(Schema.Boolean),
|
||||
@@ -76,8 +158,8 @@ export const ToolCall = Schema.Struct({
|
||||
export type ToolCall = Schema.Schema.Type<typeof ToolCall>
|
||||
|
||||
export const ToolResult = Schema.Struct({
|
||||
type: Schema.Literal("tool-result"),
|
||||
id: Schema.String,
|
||||
type: Schema.tag("tool-result"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
result: ToolResultValue,
|
||||
providerExecuted: Schema.optional(Schema.Boolean),
|
||||
@@ -86,8 +168,8 @@ export const ToolResult = Schema.Struct({
|
||||
export type ToolResult = Schema.Schema.Type<typeof ToolResult>
|
||||
|
||||
export const ToolError = Schema.Struct({
|
||||
type: Schema.Literal("tool-error"),
|
||||
id: Schema.String,
|
||||
type: Schema.tag("tool-error"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
message: Schema.String,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
@@ -95,7 +177,7 @@ export const ToolError = Schema.Struct({
|
||||
export type ToolError = Schema.Schema.Type<typeof ToolError>
|
||||
|
||||
export const StepFinish = Schema.Struct({
|
||||
type: Schema.Literal("step-finish"),
|
||||
type: Schema.tag("step-finish"),
|
||||
index: Schema.Number,
|
||||
reason: FinishReason,
|
||||
usage: Schema.optional(Usage),
|
||||
@@ -104,7 +186,7 @@ export const StepFinish = Schema.Struct({
|
||||
export type StepFinish = Schema.Schema.Type<typeof StepFinish>
|
||||
|
||||
export const RequestFinish = Schema.Struct({
|
||||
type: Schema.Literal("request-finish"),
|
||||
type: Schema.tag("request-finish"),
|
||||
reason: FinishReason,
|
||||
usage: Schema.optional(Usage),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
@@ -112,7 +194,7 @@ export const RequestFinish = Schema.Struct({
|
||||
export type RequestFinish = Schema.Schema.Type<typeof RequestFinish>
|
||||
|
||||
export const ProviderErrorEvent = Schema.Struct({
|
||||
type: Schema.Literal("provider-error"),
|
||||
type: Schema.tag("provider-error"),
|
||||
message: Schema.String,
|
||||
retryable: Schema.optional(Schema.Boolean),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
@@ -125,8 +207,12 @@ const llmEventTagged = Schema.Union([
|
||||
TextStart,
|
||||
TextDelta,
|
||||
TextEnd,
|
||||
ReasoningStart,
|
||||
ReasoningDelta,
|
||||
ReasoningEnd,
|
||||
ToolInputStart,
|
||||
ToolInputDelta,
|
||||
ToolInputEnd,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
ToolError,
|
||||
@@ -135,20 +221,52 @@ const llmEventTagged = Schema.Union([
|
||||
ProviderErrorEvent,
|
||||
]).pipe(Schema.toTaggedUnion("type"))
|
||||
|
||||
type WithID<Event extends { readonly id: unknown }, ID> = Omit<Event, "type" | "id"> & { readonly id: ID | string }
|
||||
|
||||
const responseID = (value: ResponseID | string) => ResponseID.make(value)
|
||||
const contentBlockID = (value: ContentBlockID | string) => ContentBlockID.make(value)
|
||||
const toolCallID = (value: ToolCallID | string) => ToolCallID.make(value)
|
||||
|
||||
/**
|
||||
* camelCase aliases for `LLMEvent.guards` (provided by `Schema.toTaggedUnion`).
|
||||
* Lets consumers write `events.filter(LLMEvent.is.toolCall)` instead of
|
||||
* `events.filter(LLMEvent.guards["tool-call"])`.
|
||||
*/
|
||||
export const LLMEvent = Object.assign(llmEventTagged, {
|
||||
requestStart: (input: WithID<RequestStart, ResponseID>) => RequestStart.make({ ...input, id: responseID(input.id) }),
|
||||
stepStart: StepStart.make,
|
||||
textStart: (input: WithID<TextStart, ContentBlockID>) => TextStart.make({ ...input, id: contentBlockID(input.id) }),
|
||||
textDelta: (input: WithID<TextDelta, ContentBlockID>) => TextDelta.make({ ...input, id: contentBlockID(input.id) }),
|
||||
textEnd: (input: WithID<TextEnd, ContentBlockID>) => TextEnd.make({ ...input, id: contentBlockID(input.id) }),
|
||||
reasoningStart: (input: WithID<ReasoningStart, ContentBlockID>) =>
|
||||
ReasoningStart.make({ ...input, id: contentBlockID(input.id) }),
|
||||
reasoningDelta: (input: WithID<ReasoningDelta, ContentBlockID>) =>
|
||||
ReasoningDelta.make({ ...input, id: contentBlockID(input.id) }),
|
||||
reasoningEnd: (input: WithID<ReasoningEnd, ContentBlockID>) =>
|
||||
ReasoningEnd.make({ ...input, id: contentBlockID(input.id) }),
|
||||
toolInputStart: (input: WithID<ToolInputStart, ToolCallID>) =>
|
||||
ToolInputStart.make({ ...input, id: toolCallID(input.id) }),
|
||||
toolInputDelta: (input: WithID<ToolInputDelta, ToolCallID>) =>
|
||||
ToolInputDelta.make({ ...input, id: toolCallID(input.id) }),
|
||||
toolInputEnd: (input: WithID<ToolInputEnd, ToolCallID>) => ToolInputEnd.make({ ...input, id: toolCallID(input.id) }),
|
||||
toolCall: (input: WithID<ToolCall, ToolCallID>) => ToolCall.make({ ...input, id: toolCallID(input.id) }),
|
||||
toolResult: (input: WithID<ToolResult, ToolCallID>) => ToolResult.make({ ...input, id: toolCallID(input.id) }),
|
||||
toolError: (input: WithID<ToolError, ToolCallID>) => ToolError.make({ ...input, id: toolCallID(input.id) }),
|
||||
stepFinish: StepFinish.make,
|
||||
requestFinish: RequestFinish.make,
|
||||
providerError: ProviderErrorEvent.make,
|
||||
is: {
|
||||
requestStart: llmEventTagged.guards["request-start"],
|
||||
stepStart: llmEventTagged.guards["step-start"],
|
||||
textStart: llmEventTagged.guards["text-start"],
|
||||
textDelta: llmEventTagged.guards["text-delta"],
|
||||
textEnd: llmEventTagged.guards["text-end"],
|
||||
reasoningStart: llmEventTagged.guards["reasoning-start"],
|
||||
reasoningDelta: llmEventTagged.guards["reasoning-delta"],
|
||||
reasoningEnd: llmEventTagged.guards["reasoning-end"],
|
||||
toolInputStart: llmEventTagged.guards["tool-input-start"],
|
||||
toolInputDelta: llmEventTagged.guards["tool-input-delta"],
|
||||
toolInputEnd: llmEventTagged.guards["tool-input-end"],
|
||||
toolCall: llmEventTagged.guards["tool-call"],
|
||||
toolResult: llmEventTagged.guards["tool-result"],
|
||||
toolError: llmEventTagged.guards["tool-error"],
|
||||
|
||||
@@ -14,6 +14,15 @@ export type ModelID = typeof ModelID.Type
|
||||
export const ProviderID = Schema.String.pipe(Schema.brand("LLM.ProviderID"))
|
||||
export type ProviderID = typeof ProviderID.Type
|
||||
|
||||
export const ResponseID = Schema.String
|
||||
export type ResponseID = Schema.Schema.Type<typeof ResponseID>
|
||||
|
||||
export const ContentBlockID = Schema.String
|
||||
export type ContentBlockID = Schema.Schema.Type<typeof ContentBlockID>
|
||||
|
||||
export const ToolCallID = Schema.String
|
||||
export type ToolCallID = Schema.Schema.Type<typeof ToolCallID>
|
||||
|
||||
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
|
||||
export const ReasoningEffort = Schema.Literals(ReasoningEfforts)
|
||||
export type ReasoningEffort = Schema.Schema.Type<typeof ReasoningEffort>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { JsonSchema, MessageRole, ProviderMetadata } from "./ids"
|
||||
import { CacheHint, GenerationOptions, HttpOptions, ModelRef, ProviderOptions } from "./options"
|
||||
import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, ModelRef, ProviderOptions } from "./options"
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
@@ -79,6 +79,7 @@ export const ToolResultPart = Object.assign(
|
||||
name: Schema.String,
|
||||
result: ToolResultValue,
|
||||
providerExecuted: Schema.optional(Schema.Boolean),
|
||||
cache: Schema.optional(CacheHint),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Content.ToolResult" }),
|
||||
@@ -94,6 +95,7 @@ export const ToolResultPart = Object.assign(
|
||||
name: input.name,
|
||||
result: ToolResultValue.make(input.result, input.resultType),
|
||||
providerExecuted: input.providerExecuted,
|
||||
cache: input.cache,
|
||||
metadata: input.metadata,
|
||||
providerMetadata: input.providerMetadata,
|
||||
}),
|
||||
@@ -151,6 +153,7 @@ export class ToolDefinition extends Schema.Class<ToolDefinition>("LLM.ToolDefini
|
||||
name: Schema.String,
|
||||
description: Schema.String,
|
||||
inputSchema: JsonSchema,
|
||||
cache: Schema.optional(CacheHint),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}) {}
|
||||
@@ -203,6 +206,7 @@ export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
|
||||
providerOptions: Schema.optional(ProviderOptions),
|
||||
http: Schema.optional(HttpOptions),
|
||||
responseFormat: Schema.optional(ResponseFormat),
|
||||
cache: Schema.optional(CachePolicy),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}) {}
|
||||
|
||||
@@ -220,6 +224,7 @@ export namespace LLMRequest {
|
||||
providerOptions: request.providerOptions,
|
||||
http: request.http,
|
||||
responseFormat: request.responseFormat,
|
||||
cache: request.cache,
|
||||
metadata: request.metadata,
|
||||
})
|
||||
|
||||
|
||||
@@ -200,3 +200,31 @@ export class CacheHint extends Schema.Class<CacheHint>("LLM.CacheHint")({
|
||||
type: Schema.Literals(["ephemeral", "persistent"]),
|
||||
ttlSeconds: Schema.optional(Schema.Number),
|
||||
}) {}
|
||||
|
||||
// Auto-placement policy for prompt caching. The protocol-neutral lowering step
|
||||
// reads this and injects `CacheHint`s at the configured boundaries; the
|
||||
// per-protocol body builders then translate those hints into wire markers as
|
||||
// usual. `"auto"` is the recommended default for agent loops — it places one
|
||||
// breakpoint at the last tool definition, one at the last system part, and one
|
||||
// at the latest user message. The combination of provider invalidation
|
||||
// hierarchy (tools → system → messages) and Anthropic/Bedrock's 20-block
|
||||
// lookback means three trailing breakpoints reliably cover the static prefix.
|
||||
//
|
||||
// Pass `"none"` to opt out entirely (the legacy behavior). Pass the granular
|
||||
// object form to override individual choices.
|
||||
export const CachePolicyObject = Schema.Struct({
|
||||
tools: Schema.optional(Schema.Boolean),
|
||||
system: Schema.optional(Schema.Boolean),
|
||||
messages: Schema.optional(
|
||||
Schema.Union([
|
||||
Schema.Literal("latest-user-message"),
|
||||
Schema.Literal("latest-assistant"),
|
||||
Schema.Struct({ tail: Schema.Number }),
|
||||
]),
|
||||
),
|
||||
ttlSeconds: Schema.optional(Schema.Number),
|
||||
})
|
||||
export type CachePolicyObject = Schema.Schema.Type<typeof CachePolicyObject>
|
||||
|
||||
export const CachePolicy = Schema.Union([Schema.Literal("auto"), Schema.Literal("none"), CachePolicyObject])
|
||||
export type CachePolicy = Schema.Schema.Type<typeof CachePolicy>
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type ContentPart,
|
||||
type FinishReason,
|
||||
type LLMError,
|
||||
type LLMEvent,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
Message,
|
||||
type ProviderMetadata,
|
||||
@@ -115,11 +115,19 @@ interface StepState {
|
||||
|
||||
const accumulate = (state: StepState, event: LLMEvent) => {
|
||||
if (event.type === "text-delta") {
|
||||
appendStreamingText(state, "text", event.text, event.providerMetadata)
|
||||
appendStreamingText(state, "text", event.text, undefined)
|
||||
return
|
||||
}
|
||||
if (event.type === "reasoning-delta") {
|
||||
appendStreamingText(state, "reasoning", event.text, event.providerMetadata)
|
||||
appendStreamingText(state, "reasoning", event.text, undefined)
|
||||
return
|
||||
}
|
||||
if (event.type === "reasoning-end") {
|
||||
appendStreamingText(state, "reasoning", "", event.providerMetadata)
|
||||
return
|
||||
}
|
||||
if (event.type === "text-end") {
|
||||
appendStreamingText(state, "text", "", event.providerMetadata)
|
||||
return
|
||||
}
|
||||
if (event.type === "tool-call") {
|
||||
@@ -219,10 +227,10 @@ const decodeAndExecute = (tool: AnyTool, input: unknown): Effect.Effect<ToolResu
|
||||
const emitEvents = (call: ToolCallPart, result: ToolResultValue): ReadonlyArray<LLMEvent> =>
|
||||
result.type === "error"
|
||||
? [
|
||||
{ type: "tool-error", id: call.id, name: call.name, message: String(result.value) },
|
||||
{ type: "tool-result", id: call.id, name: call.name, result },
|
||||
LLMEvent.toolError({ id: call.id, name: call.name, message: String(result.value) }),
|
||||
LLMEvent.toolResult({ id: call.id, name: call.name, result }),
|
||||
]
|
||||
: [{ type: "tool-result", id: call.id, name: call.name, result }]
|
||||
: [LLMEvent.toolResult({ id: call.id, name: call.name, result })]
|
||||
|
||||
const followUpRequest = (
|
||||
request: LLMRequest,
|
||||
|
||||
@@ -50,7 +50,9 @@ const request = LLM.request({
|
||||
})
|
||||
|
||||
const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent =>
|
||||
event.type === "finish" ? { type: "request-finish", reason: event.reason } : { type: "text-delta", text: event.text }
|
||||
event.type === "finish"
|
||||
? { type: "request-finish", reason: event.reason }
|
||||
: { type: "text-delta", id: "text-0", text: event.text }
|
||||
|
||||
const fakeProtocol = Protocol.make<FakeBody, FakeEvent, FakeEvent, void>({
|
||||
id: "fake",
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CacheHint, LLM } from "../src"
|
||||
import { LLMClient } from "../src/route"
|
||||
import * as AnthropicMessages from "../src/protocols/anthropic-messages"
|
||||
import * as BedrockConverse from "../src/protocols/bedrock-converse"
|
||||
import * as Gemini from "../src/protocols/gemini"
|
||||
import * as OpenAIChat from "../src/protocols/openai-chat"
|
||||
import { applyCachePolicy } from "../src/cache-policy"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const anthropicModel = AnthropicMessages.model({
|
||||
id: "claude-sonnet-4-5",
|
||||
baseURL: "https://api.anthropic.test/v1/",
|
||||
headers: { "x-api-key": "test" },
|
||||
})
|
||||
|
||||
const bedrockModel = BedrockConverse.model({
|
||||
id: "anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
credentials: { region: "us-east-1", accessKeyId: "fixture", secretAccessKey: "fixture" },
|
||||
})
|
||||
|
||||
const openaiModel = OpenAIChat.model({
|
||||
id: "gpt-4o-mini",
|
||||
baseURL: "https://api.openai.test/v1/",
|
||||
headers: { authorization: "Bearer test" },
|
||||
})
|
||||
|
||||
const geminiModel = Gemini.model({
|
||||
id: "gemini-2.5-flash",
|
||||
baseURL: "https://generativelanguage.test/v1beta/",
|
||||
headers: { "x-goog-api-key": "test" },
|
||||
})
|
||||
|
||||
describe("applyCachePolicy", () => {
|
||||
it.effect("undefined cache resolves to 'auto' (the recommended default)", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
system: "You are concise.",
|
||||
prompt: "hi",
|
||||
}),
|
||||
)
|
||||
|
||||
// No explicit cache field → auto policy fires → last system part + latest
|
||||
// user message both get cache_control markers.
|
||||
expect(prepared.body).toMatchObject({
|
||||
system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }],
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("'auto' marks the last tool, last system part, and latest user message on Anthropic", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
system: "Sys A",
|
||||
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
|
||||
messages: [LLM.user("first user"), LLM.assistant("assistant reply"), LLM.user("latest user message")],
|
||||
cache: "auto",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
tools: [{ name: "t1", cache_control: { type: "ephemeral" } }],
|
||||
system: [{ type: "text", text: "Sys A", cache_control: { type: "ephemeral" } }],
|
||||
messages: [
|
||||
{ role: "user", content: [{ type: "text", text: "first user" }] },
|
||||
{ role: "assistant", content: [{ type: "text", text: "assistant reply" }] },
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "latest user message", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: openaiModel,
|
||||
system: "Sys",
|
||||
prompt: "hi",
|
||||
cache: "auto",
|
||||
}),
|
||||
)
|
||||
|
||||
const body = prepared.body as { messages: Array<{ content: unknown }> }
|
||||
// OpenAI doesn't accept cache_control on messages — policy must skip.
|
||||
const flat = JSON.stringify(body)
|
||||
expect(flat).not.toContain("cache_control")
|
||||
expect(flat).not.toContain("cachePoint")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("'auto' is a no-op on Gemini (out-of-band caching protocol)", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: geminiModel,
|
||||
system: "Sys",
|
||||
prompt: "hi",
|
||||
cache: "auto",
|
||||
}),
|
||||
)
|
||||
|
||||
const flat = JSON.stringify(prepared.body)
|
||||
expect(flat).not.toContain("cache_control")
|
||||
expect(flat).not.toContain("cachePoint")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("'auto' on Bedrock emits cachePoint markers in the right places", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: bedrockModel,
|
||||
system: "Sys",
|
||||
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
|
||||
messages: [LLM.user("first user"), LLM.assistant("reply"), LLM.user("latest user")],
|
||||
cache: "auto",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
toolConfig: {
|
||||
tools: [{ toolSpec: { name: "t1" } }, { cachePoint: { type: "default" } }],
|
||||
},
|
||||
system: [{ text: "Sys" }, { cachePoint: { type: "default" } }],
|
||||
messages: [
|
||||
{ role: "user", content: [{ text: "first user" }] },
|
||||
{ role: "assistant", content: [{ text: "reply" }] },
|
||||
{ role: "user", content: [{ text: "latest user" }, { cachePoint: { type: "default" } }] },
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("'none' disables auto placement even when manual hints exist", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
system: "Sys",
|
||||
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
|
||||
prompt: "hi",
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
tools: [{ name: "t1", cache_control: undefined }],
|
||||
system: [{ type: "text", text: "Sys", cache_control: undefined }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("granular object form: tools-only marks just tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
system: "Sys",
|
||||
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
|
||||
prompt: "hi",
|
||||
cache: { tools: true },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
tools: [{ name: "t1", cache_control: { type: "ephemeral" } }],
|
||||
system: [{ type: "text", text: "Sys", cache_control: undefined }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("auto policy preserves manual CacheHints on other parts", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
system: [
|
||||
{ type: "text", text: "first system", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) },
|
||||
{ type: "text", text: "last system" },
|
||||
],
|
||||
prompt: "hi",
|
||||
cache: "auto",
|
||||
}),
|
||||
)
|
||||
|
||||
const body = prepared.body as { system: Array<{ text: string; cache_control?: unknown }> }
|
||||
expect(body.system[0]?.cache_control).toEqual({ type: "ephemeral", ttl: "1h" })
|
||||
expect(body.system[1]?.cache_control).toEqual({ type: "ephemeral" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ttlSeconds in the policy flows through to wire markers", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
system: "Sys",
|
||||
prompt: "hi",
|
||||
cache: { system: true, ttlSeconds: 3600 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
system: [{ type: "text", text: "Sys", cache_control: { type: "ephemeral", ttl: "1h" } }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("messages: { tail: 2 } marks the last 2 message boundaries", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
messages: [LLM.user("u1"), LLM.assistant("a1"), LLM.user("u2"), LLM.assistant("a2")],
|
||||
cache: { messages: { tail: 2 } },
|
||||
}),
|
||||
)
|
||||
|
||||
const body = prepared.body as { messages: Array<{ content: Array<{ cache_control?: unknown }> }> }
|
||||
expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined()
|
||||
expect(body.messages[1]?.content[0]?.cache_control).toBeUndefined()
|
||||
expect(body.messages[2]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
|
||||
expect(body.messages[3]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("'latest-assistant' marks the last assistant message", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
messages: [LLM.user("u1"), LLM.assistant("a1"), LLM.user("u2")],
|
||||
cache: { messages: "latest-assistant" },
|
||||
}),
|
||||
)
|
||||
|
||||
const body = prepared.body as { messages: Array<{ content: Array<{ cache_control?: unknown }> }> }
|
||||
expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined()
|
||||
expect(body.messages[1]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
|
||||
expect(body.messages[2]?.content[0]?.cache_control).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
test("returns the same request reference when policy is a no-op (pure function)", () => {
|
||||
const request = LLM.request({
|
||||
model: anthropicModel,
|
||||
prompt: "hi",
|
||||
cache: "none",
|
||||
})
|
||||
expect(applyCachePolicy(request)).toBe(request)
|
||||
})
|
||||
})
|
||||
+48
File diff suppressed because one or more lines are too long
+46
File diff suppressed because one or more lines are too long
+46
File diff suppressed because one or more lines are too long
@@ -126,7 +126,7 @@ describe("llm constructors", () => {
|
||||
expect(
|
||||
LLMResponse.text({
|
||||
events: [
|
||||
{ type: "text-delta", text: "hi" },
|
||||
{ type: "text-delta", id: "text-0", text: "hi" },
|
||||
{ type: "request-finish", reason: "stop" },
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Redactor } from "@opencode-ai/http-recorder"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CacheHint, LLM } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
|
||||
import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios"
|
||||
import { recordedTests } from "../recorded-test"
|
||||
|
||||
const model = AnthropicMessages.model({
|
||||
id: "claude-haiku-4-5-20251001",
|
||||
apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture",
|
||||
})
|
||||
|
||||
// Two identical generations in a row. The first call writes the prefix into
|
||||
// Anthropic's cache; the second should report a cache read against the same
|
||||
// prefix. Cassette captures both interactions in order.
|
||||
const cacheRequest = LLM.request({
|
||||
id: "recorded_anthropic_cache",
|
||||
model,
|
||||
system: [{ type: "text", text: LARGE_CACHEABLE_SYSTEM, cache: new CacheHint({ type: "ephemeral" }) }],
|
||||
prompt: "Say hi.",
|
||||
// Manual hint on the system part is the only marker we want here — skip the
|
||||
// auto-policy's latest-user-message breakpoint so the cassette body matches.
|
||||
cache: "none",
|
||||
generation: { maxTokens: 16, temperature: 0 },
|
||||
})
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "anthropic-messages-cache",
|
||||
provider: "anthropic",
|
||||
protocol: "anthropic-messages",
|
||||
requires: ["ANTHROPIC_API_KEY"],
|
||||
// Two identical requests in one cassette — match by recording order so the
|
||||
// second call replays the cached-hit interaction.
|
||||
options: {
|
||||
dispatch: "sequential",
|
||||
redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }),
|
||||
},
|
||||
})
|
||||
|
||||
describe("Anthropic Messages cache recorded", () => {
|
||||
recorded.effect.with("writes then reads cache_control on identical second call", { tags: ["cache"] }, () =>
|
||||
Effect.gen(function* () {
|
||||
const first = yield* LLMClient.generate(cacheRequest)
|
||||
// The first call may write the cache (cacheWriteInputTokens > 0) or it
|
||||
// may be a fresh miss (both fields 0) depending on whether the prefix is
|
||||
// already warm on Anthropic's side. The assertion that matters is that
|
||||
// the SECOND call reports a non-zero cache read.
|
||||
expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0)
|
||||
|
||||
const second = yield* LLMClient.generate(cacheRequest)
|
||||
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CacheHint, LLM, LLMError } from "../../src"
|
||||
import { CacheHint, LLM, LLMError, Usage } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
|
||||
import { it } from "../lib/effect"
|
||||
@@ -18,6 +18,9 @@ const request = LLM.request({
|
||||
model,
|
||||
system: { type: "text", text: "You are concise.", cache: new CacheHint({ type: "ephemeral" }) },
|
||||
prompt: "Say hello.",
|
||||
// This fixture predates the `cache: "auto"` default; pin the policy off so
|
||||
// existing wire-shape assertions only see the manual hint on the system part.
|
||||
cache: "none",
|
||||
generation: { maxTokens: 20, temperature: 0 },
|
||||
})
|
||||
|
||||
@@ -48,6 +51,7 @@ describe("Anthropic Messages route", () => {
|
||||
LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
|
||||
LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -110,12 +114,13 @@ describe("Anthropic Messages route", () => {
|
||||
expect(response.text).toBe("Hello!")
|
||||
expect(response.reasoning).toBe("thinking")
|
||||
expect(response.usage).toMatchObject({
|
||||
inputTokens: 5,
|
||||
inputTokens: 6,
|
||||
outputTokens: 2,
|
||||
nonCachedInputTokens: 5,
|
||||
cacheReadInputTokens: 1,
|
||||
totalTokens: 7,
|
||||
totalTokens: 8,
|
||||
})
|
||||
expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toMatchObject({
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
|
||||
providerMetadata: { anthropic: { signature: "sig_1" } },
|
||||
})
|
||||
expect(response.events.at(-1)).toMatchObject({
|
||||
@@ -152,7 +157,13 @@ describe("Anthropic Messages route", () => {
|
||||
{
|
||||
type: "request-finish",
|
||||
reason: "tool-calls",
|
||||
usage: { inputTokens: 5, outputTokens: 1, totalTokens: 6, native: { input_tokens: 5, output_tokens: 1 } },
|
||||
usage: new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 1,
|
||||
nonCachedInputTokens: 5,
|
||||
totalTokens: 6,
|
||||
providerMetadata: { anthropic: { input_tokens: 5, output_tokens: 1 } },
|
||||
}),
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -374,4 +385,134 @@ describe("Anthropic Messages route", () => {
|
||||
expect(error.message).toContain("Anthropic Messages user messages only support text content for now")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps ttlSeconds >= 3600 to cache_control ttl: '1h'", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
system: { type: "text", text: "system", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) },
|
||||
prompt: "hi",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
system: [{ type: "text", text: "system", cache_control: { type: "ephemeral", ttl: "1h" } }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits cache_control on tool definitions and tool-result blocks", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
tools: [
|
||||
{
|
||||
name: "lookup",
|
||||
description: "lookup tool",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
cache: new CacheHint({ type: "ephemeral" }),
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
LLM.user("What's the weather?"),
|
||||
LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: {} })]),
|
||||
LLM.toolMessage({
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
result: { temp: 72 },
|
||||
cache: new CacheHint({ type: "ephemeral" }),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
tools: [{ name: "lookup", cache_control: { type: "ephemeral" } }],
|
||||
messages: [
|
||||
{ role: "user", content: [{ type: "text", text: "What's the weather?" }] },
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup" }] },
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "call_1", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("drops cache_control breakpoints past the 4-per-request cap", () =>
|
||||
Effect.gen(function* () {
|
||||
const hint = new CacheHint({ type: "ephemeral" })
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
system: [
|
||||
{ type: "text", text: "a", cache: hint },
|
||||
{ type: "text", text: "b", cache: hint },
|
||||
{ type: "text", text: "c", cache: hint },
|
||||
{ type: "text", text: "d", cache: hint },
|
||||
{ type: "text", text: "e", cache: hint },
|
||||
{ type: "text", text: "f", cache: hint },
|
||||
],
|
||||
prompt: "hi",
|
||||
}),
|
||||
)
|
||||
|
||||
const system = (prepared.body as { system: Array<{ cache_control?: unknown }> }).system
|
||||
const marked = system.filter((part) => part.cache_control !== undefined)
|
||||
expect(marked).toHaveLength(4)
|
||||
expect(system[4]?.cache_control).toBeUndefined()
|
||||
expect(system[5]?.cache_control).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("spends breakpoint budget on tools before system before messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const hint = new CacheHint({ type: "ephemeral" })
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
tools: [
|
||||
{
|
||||
name: "t1",
|
||||
description: "t1",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
cache: hint,
|
||||
},
|
||||
{
|
||||
name: "t2",
|
||||
description: "t2",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
cache: hint,
|
||||
},
|
||||
{
|
||||
name: "t3",
|
||||
description: "t3",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
cache: hint,
|
||||
},
|
||||
{
|
||||
name: "t4",
|
||||
description: "t4",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
cache: hint,
|
||||
},
|
||||
],
|
||||
system: [{ type: "text", text: "system-tail", cache: hint }],
|
||||
messages: [LLM.user([{ type: "text", text: "message-tail", cache: hint }])],
|
||||
}),
|
||||
)
|
||||
|
||||
const body = prepared.body as {
|
||||
tools: Array<{ cache_control?: unknown }>
|
||||
system: Array<{ cache_control?: unknown }>
|
||||
messages: Array<{ content: Array<{ cache_control?: unknown }> }>
|
||||
}
|
||||
expect(body.tools.every((t) => t.cache_control !== undefined)).toBe(true)
|
||||
expect(body.system[0]?.cache_control).toBeUndefined()
|
||||
expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CacheHint, LLM } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import * as BedrockConverse from "../../src/protocols/bedrock-converse"
|
||||
import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios"
|
||||
import { recordedTests } from "../recorded-test"
|
||||
|
||||
const RECORDING_REGION = process.env.BEDROCK_RECORDING_REGION ?? "us-east-1"
|
||||
|
||||
// Use a Claude model on Bedrock — Nova has automatic prefix caching that
|
||||
// doesn't reliably surface `cacheRead`/`cacheWrite` in usage, so the second
|
||||
// call wouldn't deterministically prove cache mapping works. Override with
|
||||
// BEDROCK_CACHE_MODEL_ID if your account has access elsewhere.
|
||||
const model = BedrockConverse.model({
|
||||
id: process.env.BEDROCK_CACHE_MODEL_ID ?? "us.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
credentials: {
|
||||
region: RECORDING_REGION,
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "fixture",
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "fixture",
|
||||
sessionToken: process.env.AWS_SESSION_TOKEN,
|
||||
},
|
||||
})
|
||||
|
||||
const cacheRequest = LLM.request({
|
||||
id: "recorded_bedrock_cache",
|
||||
model,
|
||||
system: [{ type: "text", text: LARGE_CACHEABLE_SYSTEM, cache: new CacheHint({ type: "ephemeral" }) }],
|
||||
prompt: "Say hi.",
|
||||
// Manual hint on the system part is the only marker we want here — skip the
|
||||
// auto-policy's latest-user-message breakpoint so the cassette body matches.
|
||||
cache: "none",
|
||||
generation: { maxTokens: 16, temperature: 0 },
|
||||
})
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "bedrock-converse-cache",
|
||||
provider: "amazon-bedrock",
|
||||
protocol: "bedrock-converse",
|
||||
requires: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
|
||||
// Two identical requests in one cassette — match by recording order so the
|
||||
// second call replays the cached-hit interaction.
|
||||
options: { dispatch: "sequential" },
|
||||
})
|
||||
|
||||
describe("Bedrock Converse cache recorded", () => {
|
||||
recorded.effect.with("writes then reads cachePoint on identical second call", { tags: ["cache"] }, () =>
|
||||
Effect.gen(function* () {
|
||||
const first = yield* LLMClient.generate(cacheRequest)
|
||||
expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0)
|
||||
|
||||
const second = yield* LLMClient.generate(cacheRequest)
|
||||
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -63,6 +63,9 @@ const baseRequest = LLM.request({
|
||||
model,
|
||||
system: "You are concise.",
|
||||
prompt: "Say hello.",
|
||||
// Wire-shape assertions in this file predate the `cache: "auto"` default;
|
||||
// pin the policy off so they only exercise the lowering path itself.
|
||||
cache: "none",
|
||||
generation: { maxTokens: 64, temperature: 0 },
|
||||
})
|
||||
|
||||
@@ -125,6 +128,7 @@ describe("Bedrock Converse route", () => {
|
||||
LLM.assistant([LLM.toolCall({ id: "tool_1", name: "lookup", input: { query: "weather" } })]),
|
||||
LLM.toolMessage({ id: "tool_1", name: "lookup", result: { forecast: "sunny" } }),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -339,6 +343,7 @@ describe("Bedrock Converse route", () => {
|
||||
{ type: "media", mediaType: "image/webp", data: "DDDD" },
|
||||
]),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -440,6 +445,78 @@ describe("Bedrock Converse route", () => {
|
||||
expect(error.message).toContain("Bedrock Converse does not support media type application/x-tar")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps ttlSeconds >= 3600 to cachePoint ttl: '1h'", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = new CacheHint({ type: "ephemeral", ttlSeconds: 3600 })
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
system: [{ type: "text", text: "system", cache }],
|
||||
prompt: "hi",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
system: [{ text: "system" }, { cachePoint: { type: "default", ttl: "1h" } }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("appends cachePoint after marked tool definitions and tool-result blocks", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = new CacheHint({ type: "ephemeral" })
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
tools: [{ name: "lookup", description: "lookup", inputSchema: { type: "object", properties: {} }, cache }],
|
||||
messages: [
|
||||
LLM.user("What's the weather?"),
|
||||
LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: {} })]),
|
||||
LLM.toolMessage({ id: "call_1", name: "lookup", result: { temp: 72 }, cache }),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
toolConfig: {
|
||||
tools: [{ toolSpec: { name: "lookup" } }, { cachePoint: { type: "default" } }],
|
||||
},
|
||||
messages: [
|
||||
{ role: "user", content: [{ text: "What's the weather?" }] },
|
||||
{ role: "assistant", content: [{ toolUse: { toolUseId: "call_1" } }] },
|
||||
{
|
||||
role: "user",
|
||||
content: [{ toolResult: { toolUseId: "call_1" } }, { cachePoint: { type: "default" } }],
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("drops cachePoint markers past the 4-per-request cap", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = new CacheHint({ type: "ephemeral" })
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
system: [
|
||||
{ type: "text", text: "a", cache },
|
||||
{ type: "text", text: "b", cache },
|
||||
{ type: "text", text: "c", cache },
|
||||
{ type: "text", text: "d", cache },
|
||||
{ type: "text", text: "e", cache },
|
||||
{ type: "text", text: "f", cache },
|
||||
],
|
||||
prompt: "hi",
|
||||
}),
|
||||
)
|
||||
|
||||
const system = (prepared.body as { system: Array<{ cachePoint?: unknown }> }).system
|
||||
expect(system.filter((part) => "cachePoint" in part)).toHaveLength(4)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// Live recorded integration tests. Run with `RECORD=true AWS_ACCESS_KEY_ID=...
|
||||
@@ -484,6 +561,7 @@ describe("Bedrock Converse recorded", () => {
|
||||
model: recordedModel(),
|
||||
system: "Reply with the single word 'Hello'.",
|
||||
prompt: "Say hello.",
|
||||
cache: "none",
|
||||
generation: { maxTokens: 16, temperature: 0 },
|
||||
}),
|
||||
)
|
||||
@@ -506,6 +584,7 @@ describe("Bedrock Converse recorded", () => {
|
||||
prompt: "Call get_weather with city exactly Paris.",
|
||||
tools: [weatherTool],
|
||||
toolChoice: LLM.toolChoice(weatherTool),
|
||||
cache: "none",
|
||||
generation: { maxTokens: 80, temperature: 0 },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import * as Gemini from "../../src/protocols/gemini"
|
||||
import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios"
|
||||
import { recordedTests } from "../recorded-test"
|
||||
|
||||
const model = Gemini.model({
|
||||
id: "gemini-2.5-flash",
|
||||
apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? process.env.GEMINI_API_KEY ?? "fixture",
|
||||
})
|
||||
|
||||
// Gemini does implicit prefix caching on 2.5+ models above ~1024 tokens. The
|
||||
// `CacheHint` is currently a no-op for Gemini (the explicit `CachedContent`
|
||||
// API is out-of-band and intentionally not wired up). This test exists to
|
||||
// pin the usage-parsing path: `cachedContentTokenCount` should surface as
|
||||
// `cacheReadInputTokens` on the second identical call.
|
||||
const cacheRequest = LLM.request({
|
||||
id: "recorded_gemini_cache",
|
||||
model,
|
||||
system: LARGE_CACHEABLE_SYSTEM,
|
||||
prompt: "Say hi.",
|
||||
generation: { maxTokens: 16, temperature: 0 },
|
||||
})
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "gemini-cache",
|
||||
provider: "google",
|
||||
protocol: "gemini",
|
||||
requires: ["GOOGLE_GENERATIVE_AI_API_KEY"],
|
||||
// Two identical requests in one cassette — match by recording order so the
|
||||
// second call replays the cached-hit interaction.
|
||||
options: { dispatch: "sequential" },
|
||||
})
|
||||
|
||||
describe("Gemini cache recorded", () => {
|
||||
recorded.effect.with("reports cachedContentTokenCount on identical second call", { tags: ["cache"] }, () =>
|
||||
Effect.gen(function* () {
|
||||
const first = yield* LLMClient.generate(cacheRequest)
|
||||
expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0)
|
||||
|
||||
const second = yield* LLMClient.generate(cacheRequest)
|
||||
// Implicit caching is best-effort on Gemini's side; we assert the field
|
||||
// is at least populated and non-negative. When re-recording, verify the
|
||||
// cassette shows > 0 in the second response's usage.
|
||||
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMError } from "../../src"
|
||||
import { LLM, LLMError, Usage } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import * as Gemini from "../../src/protocols/gemini"
|
||||
import { it } from "../lib/effect"
|
||||
@@ -198,32 +198,36 @@ describe("Gemini route", () => {
|
||||
expect(response.reasoning).toBe("thinking")
|
||||
expect(response.usage).toMatchObject({
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
reasoningTokens: 1,
|
||||
outputTokens: 3,
|
||||
nonCachedInputTokens: 4,
|
||||
cacheReadInputTokens: 1,
|
||||
reasoningTokens: 1,
|
||||
totalTokens: 7,
|
||||
})
|
||||
expect(response.events).toEqual([
|
||||
{ type: "reasoning-delta", text: "thinking" },
|
||||
{ type: "text-delta", text: "Hello" },
|
||||
{ type: "text-delta", text: "!" },
|
||||
{ type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
|
||||
{ type: "text-delta", id: "text-0", text: "Hello" },
|
||||
{ type: "text-delta", id: "text-0", text: "!" },
|
||||
{
|
||||
type: "request-finish",
|
||||
reason: "stop",
|
||||
usage: {
|
||||
usage: new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
reasoningTokens: 1,
|
||||
outputTokens: 3,
|
||||
nonCachedInputTokens: 4,
|
||||
cacheReadInputTokens: 1,
|
||||
reasoningTokens: 1,
|
||||
totalTokens: 7,
|
||||
native: {
|
||||
promptTokenCount: 5,
|
||||
candidatesTokenCount: 2,
|
||||
totalTokenCount: 7,
|
||||
thoughtsTokenCount: 1,
|
||||
cachedContentTokenCount: 1,
|
||||
providerMetadata: {
|
||||
google: {
|
||||
promptTokenCount: 5,
|
||||
candidatesTokenCount: 2,
|
||||
totalTokenCount: 7,
|
||||
thoughtsTokenCount: 1,
|
||||
cachedContentTokenCount: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -257,12 +261,13 @@ describe("Gemini route", () => {
|
||||
{
|
||||
type: "request-finish",
|
||||
reason: "tool-calls",
|
||||
usage: {
|
||||
usage: new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 1,
|
||||
nonCachedInputTokens: 5,
|
||||
totalTokens: 6,
|
||||
native: { promptTokenCount: 5, candidatesTokenCount: 1 },
|
||||
},
|
||||
providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } },
|
||||
}),
|
||||
},
|
||||
])
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, LLMError } from "../../src"
|
||||
import { LLM, LLMError, Usage } from "../../src"
|
||||
import * as Azure from "../../src/providers/azure"
|
||||
import * as OpenAI from "../../src/providers/openai"
|
||||
import * as OpenAIChat from "../../src/protocols/openai-chat"
|
||||
@@ -225,25 +225,28 @@ describe("OpenAI Chat route", () => {
|
||||
|
||||
expect(response.text).toBe("Hello!")
|
||||
expect(response.events).toEqual([
|
||||
{ type: "text-delta", text: "Hello" },
|
||||
{ type: "text-delta", text: "!" },
|
||||
{ type: "text-delta", id: "text-0", text: "Hello" },
|
||||
{ type: "text-delta", id: "text-0", text: "!" },
|
||||
{
|
||||
type: "request-finish",
|
||||
reason: "stop",
|
||||
usage: {
|
||||
usage: new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
reasoningTokens: 0,
|
||||
nonCachedInputTokens: 4,
|
||||
cacheReadInputTokens: 1,
|
||||
reasoningTokens: 0,
|
||||
totalTokens: 7,
|
||||
native: {
|
||||
prompt_tokens: 5,
|
||||
completion_tokens: 2,
|
||||
total_tokens: 7,
|
||||
prompt_tokens_details: { cached_tokens: 1 },
|
||||
completion_tokens_details: { reasoning_tokens: 0 },
|
||||
providerMetadata: {
|
||||
openai: {
|
||||
prompt_tokens: 5,
|
||||
completion_tokens: 2,
|
||||
total_tokens: 7,
|
||||
prompt_tokens_details: { cached_tokens: 1 },
|
||||
completion_tokens_details: { reasoning_tokens: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
])
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import * as OpenAIResponses from "../../src/protocols/openai-responses"
|
||||
import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios"
|
||||
import { recordedTests } from "../recorded-test"
|
||||
|
||||
const model = OpenAIResponses.model({
|
||||
id: "gpt-4.1-mini",
|
||||
apiKey: process.env.OPENAI_API_KEY ?? "fixture",
|
||||
})
|
||||
|
||||
// OpenAI caches prefixes automatically once they cross the 1024-token threshold;
|
||||
// `CacheHint` is a no-op for the wire body. The stable signal is the
|
||||
// `prompt_cache_key` routing hint, which keeps repeated calls on the same shard
|
||||
// so cache hits are observable.
|
||||
const cacheRequest = LLM.request({
|
||||
id: "recorded_openai_responses_cache",
|
||||
model,
|
||||
system: LARGE_CACHEABLE_SYSTEM,
|
||||
prompt: "Say hi.",
|
||||
generation: { maxTokens: 16, temperature: 0 },
|
||||
providerOptions: { openai: { promptCacheKey: "recorded-cache-test" } },
|
||||
})
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "openai-responses-cache",
|
||||
provider: "openai",
|
||||
protocol: "openai-responses",
|
||||
requires: ["OPENAI_API_KEY"],
|
||||
// Two identical requests in one cassette — match by recording order so the
|
||||
// second call replays the cached-hit interaction, not the cold-miss one.
|
||||
options: { dispatch: "sequential" },
|
||||
})
|
||||
|
||||
describe("OpenAI Responses cache recorded", () => {
|
||||
recorded.effect.with("reports cached_tokens on identical second call", { tags: ["cache"] }, () =>
|
||||
Effect.gen(function* () {
|
||||
const first = yield* LLMClient.generate(cacheRequest)
|
||||
expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0)
|
||||
|
||||
const second = yield* LLMClient.generate(cacheRequest)
|
||||
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, Effect, Layer, Stream } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, LLMError } from "../../src"
|
||||
import { LLM, LLMError, Usage } from "../../src"
|
||||
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
|
||||
import * as Azure from "../../src/providers/azure"
|
||||
import * as OpenAI from "../../src/providers/openai"
|
||||
@@ -336,26 +336,29 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
expect(response.text).toBe("Hello!")
|
||||
expect(response.events).toEqual([
|
||||
{ type: "text-delta", id: "msg_1", text: "Hello", providerMetadata: { openai: { itemId: "msg_1" } } },
|
||||
{ type: "text-delta", id: "msg_1", text: "!", providerMetadata: { openai: { itemId: "msg_1" } } },
|
||||
{ type: "text-delta", id: "msg_1", text: "Hello" },
|
||||
{ type: "text-delta", id: "msg_1", text: "!" },
|
||||
{
|
||||
type: "request-finish",
|
||||
reason: "stop",
|
||||
providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
|
||||
usage: {
|
||||
usage: new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
reasoningTokens: 0,
|
||||
nonCachedInputTokens: 4,
|
||||
cacheReadInputTokens: 1,
|
||||
reasoningTokens: 0,
|
||||
totalTokens: 7,
|
||||
native: {
|
||||
input_tokens: 5,
|
||||
output_tokens: 2,
|
||||
total_tokens: 7,
|
||||
input_tokens_details: { cached_tokens: 1 },
|
||||
output_tokens_details: { reasoning_tokens: 0 },
|
||||
providerMetadata: {
|
||||
openai: {
|
||||
input_tokens: 5,
|
||||
output_tokens: 2,
|
||||
total_tokens: 7,
|
||||
input_tokens_details: { cached_tokens: 1 },
|
||||
output_tokens_details: { reasoning_tokens: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -394,14 +397,12 @@ describe("OpenAI Responses route", () => {
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query"',
|
||||
providerMetadata: { openai: { itemId: "item_1" } },
|
||||
},
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: ':"weather"}',
|
||||
providerMetadata: { openai: { itemId: "item_1" } },
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
@@ -413,7 +414,13 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "request-finish",
|
||||
reason: "tool-calls",
|
||||
usage: { inputTokens: 5, outputTokens: 1, totalTokens: 6, native: { input_tokens: 5, output_tokens: 1 } },
|
||||
usage: new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 1,
|
||||
nonCachedInputTokens: 5,
|
||||
totalTokens: 6,
|
||||
providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } },
|
||||
}),
|
||||
},
|
||||
])
|
||||
}),
|
||||
|
||||
@@ -6,6 +6,18 @@ import { tool } from "../src/tool"
|
||||
|
||||
export const weatherToolName = "get_weather"
|
||||
|
||||
// A deterministic system prompt long enough to clear every supported provider's
|
||||
// minimum cacheable-prefix threshold (Anthropic Haiku 3.5: 2048 tokens; Anthropic
|
||||
// Opus/Haiku 4.5: 4096 tokens; OpenAI/Gemini/Bedrock: lower). Built by repeating
|
||||
// a fixed sentence — the cassette replays bit-for-bit, so the exact text matters
|
||||
// only when re-recording with `RECORD=true`.
|
||||
export const LARGE_CACHEABLE_SYSTEM = (() => {
|
||||
const sentence = "You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. "
|
||||
// ~100 chars per sentence × 250 repeats ≈ 25,000 chars ≈ 5k+ tokens, safely
|
||||
// above every provider's threshold.
|
||||
return sentence.repeat(250)
|
||||
})()
|
||||
|
||||
export const weatherTool = LLM.toolDefinition({
|
||||
name: weatherToolName,
|
||||
description: "Get current weather for a city.",
|
||||
@@ -39,6 +51,7 @@ export const textRequest = (input: {
|
||||
model: input.model,
|
||||
system: "You are concise.",
|
||||
prompt: input.prompt ?? "Reply with exactly: Hello!",
|
||||
cache: "none",
|
||||
generation:
|
||||
input.temperature === false
|
||||
? { maxTokens: input.maxTokens ?? 20 }
|
||||
@@ -58,6 +71,7 @@ export const weatherToolRequest = (input: {
|
||||
prompt: "Call get_weather with city exactly Paris.",
|
||||
tools: [weatherTool],
|
||||
toolChoice: LLM.toolChoice(weatherTool),
|
||||
cache: "none",
|
||||
generation:
|
||||
input.temperature === false
|
||||
? { maxTokens: input.maxTokens ?? 80 }
|
||||
@@ -76,6 +90,7 @@ export const weatherToolLoopRequest = (input: {
|
||||
model: input.model,
|
||||
system: input.system ?? "Use the get_weather tool, then answer in one short sentence.",
|
||||
prompt: "What is the weather in Paris?",
|
||||
cache: "none",
|
||||
generation:
|
||||
input.temperature === false
|
||||
? { maxTokens: input.maxTokens ?? 80 }
|
||||
|
||||
@@ -53,7 +53,7 @@ export const recordedTests = (options: RecordedTestsOptions) =>
|
||||
...metadata,
|
||||
}
|
||||
const mode = recorderOptions?.mode ?? (recording ? "record" : "replay")
|
||||
const cassetteService = HttpRecorder.Cassette.layer({ directory: FIXTURES_DIR }).pipe(
|
||||
const cassetteService = HttpRecorder.Cassette.fileSystem({ directory: FIXTURES_DIR }).pipe(
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
)
|
||||
const requestExecutor = RequestExecutor.layer.pipe(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { ContentPart, LLMEvent, LLMRequest, ModelID, ModelLimits, ModelRef, ProviderID } from "../src/schema"
|
||||
import { ContentPart, LLMEvent, LLMRequest, ModelID, ModelLimits, ModelRef, ProviderID, Usage } from "../src/schema"
|
||||
import { ProviderShared } from "../src/protocols/shared"
|
||||
|
||||
const model = new ModelRef({
|
||||
id: ModelID.make("fake-model"),
|
||||
@@ -48,3 +49,30 @@ describe("llm schema", () => {
|
||||
expect(ContentPart.guards.media({ type: "text", text: "hi" })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("LLM.Usage", () => {
|
||||
test("subtractTokens clamps non-sensical breakdowns to zero", () => {
|
||||
// Defense against a provider reporting cached_tokens > prompt_tokens or
|
||||
// reasoning_tokens > completion_tokens — the negative would otherwise
|
||||
// round-trip through the pipeline and crash strict downstream schemas.
|
||||
expect(ProviderShared.subtractTokens(5, 3)).toBe(2)
|
||||
expect(ProviderShared.subtractTokens(5, 10)).toBe(0)
|
||||
expect(ProviderShared.subtractTokens(5, undefined)).toBe(5)
|
||||
expect(ProviderShared.subtractTokens(undefined, 3)).toBeUndefined()
|
||||
expect(ProviderShared.subtractTokens(undefined, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("sumTokens returns undefined only when every input is undefined", () => {
|
||||
expect(ProviderShared.sumTokens(1, 2, 3)).toBe(6)
|
||||
expect(ProviderShared.sumTokens(1, undefined, 3)).toBe(4)
|
||||
expect(ProviderShared.sumTokens(undefined, undefined, undefined)).toBeUndefined()
|
||||
expect(ProviderShared.sumTokens()).toBeUndefined()
|
||||
})
|
||||
|
||||
test("visibleOutputTokens clamps reasoning > output to zero", () => {
|
||||
expect(new Usage({ outputTokens: 10, reasoningTokens: 4 }).visibleOutputTokens).toBe(6)
|
||||
expect(new Usage({ outputTokens: 10 }).visibleOutputTokens).toBe(10)
|
||||
expect(new Usage({ outputTokens: 4, reasoningTokens: 10 }).visibleOutputTokens).toBe(0)
|
||||
expect(new Usage({}).visibleOutputTokens).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
- **Output**: creates `migration/<timestamp>_<slug>/migration.sql` and `snapshot.json`.
|
||||
- **Tests**: migration tests should read the per-folder layout (no `_journal.json`).
|
||||
|
||||
## Development server
|
||||
|
||||
- Running `bun dev` from `packages/opencode` starts the live interactive TUI. Do not run it as a blocking foreground command when you need to inspect the result.
|
||||
- Start it in `tmux` instead: `tmux new-session -d -s opencode-dev 'bun dev'`.
|
||||
- Capture the current TUI output with: `tmux capture-pane -pt opencode-dev`.
|
||||
- Stop the session explicitly when done: `tmux kill-session -t opencode-dev`.
|
||||
|
||||
# Module shape
|
||||
|
||||
Do not use `export namespace Foo { ... }` for module organization. It is not
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# opencode
|
||||
|
||||
The runtime that powers the opencode CLI/TUI: it manages projects on disk, runs
|
||||
agent sessions against LLM providers, and exposes a control plane so other
|
||||
clients (web, IDE, remote workspaces) can connect to the same state.
|
||||
|
||||
## Language
|
||||
|
||||
### Location & runtime
|
||||
|
||||
**Project**:
|
||||
The persisted, VCS-rooted thing the user owns — one row per repo (or per non-VCS directory).
|
||||
_Avoid_: repo, codebase, folder
|
||||
|
||||
**Worktree**:
|
||||
A git worktree of a **Project** — a branch checkout sitting at a directory on disk.
|
||||
_Avoid_: branch dir, checkout, workspace
|
||||
|
||||
**Workspace**:
|
||||
A control-plane connection target for a **Project**, identified by a `wrk_…` ID. Local or remote. Describes _how_ a client is connected to a Project, not where the Project lives on disk.
|
||||
_Avoid_: worktree, session, project (the connection is not the project itself)
|
||||
|
||||
**Instance**:
|
||||
The runtime `(project, worktree, directory)` ALS context the opencode process is currently operating inside. Not persisted; not user-visible.
|
||||
_Avoid_: session, context, runtime
|
||||
|
||||
**Directory**:
|
||||
A filesystem path string. The Instance always has one as its current working directory.
|
||||
_Avoid_: folder, cwd (in domain prose; fine in code)
|
||||
|
||||
## Relationships
|
||||
|
||||
- A **Project** has zero or more **Worktrees**.
|
||||
- A **Worktree** belongs to exactly one **Project**.
|
||||
- A **Workspace** points at exactly one **Project** (and optionally a specific branch/directory inside it).
|
||||
- An **Instance** is bound to one **Project**, one **Worktree**, and one **Directory** for the life of an async context.
|
||||
|
||||
## Example dialogue
|
||||
|
||||
> **Dev:** "When the user opens a remote target, do we create a new **Worktree**?"
|
||||
> **Maintainer:** "No — a remote target is a **Workspace** pointing at a **Project** on the other side. The remote host already has its own **Worktrees**; we just connect to one."
|
||||
|
||||
## Flagged ambiguities
|
||||
|
||||
- "workspace" was used in `src/project/project.ts` to mean **Worktree** ("Startup script to run when creating a new workspace (worktree)") — resolved: these are distinct. The start command runs when creating a **Worktree**, not a **Workspace**.
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE TABLE `data_migration` (
|
||||
`name` text PRIMARY KEY,
|
||||
`time_completed` integer NOT NULL
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.46",
|
||||
"version": "1.14.48",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -26,7 +26,6 @@ import * as Option from "effect/Option"
|
||||
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics, type DeepMutable } from "@opencode-ai/core/schema"
|
||||
import { Reference } from "@/reference/reference"
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
name: Schema.String,
|
||||
@@ -301,76 +300,6 @@ export const layer = Layer.effect(
|
||||
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
|
||||
}
|
||||
|
||||
function referencePrompt(reference: Reference.Resolved) {
|
||||
if (reference.kind === "local") {
|
||||
return [
|
||||
`You are configured reference @${reference.name}, a read-only research agent for external reference material.`,
|
||||
`Local directory: ${reference.path}`,
|
||||
`Inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`,
|
||||
`Return exact absolute file paths for findings whenever possible.`,
|
||||
].join("\n\n")
|
||||
}
|
||||
|
||||
if (reference.kind === "invalid") {
|
||||
return [
|
||||
`You are configured reference @${reference.name}, but this reference is not usable yet.`,
|
||||
`Configured repository: ${reference.repository}`,
|
||||
`Problem: ${reference.message}`,
|
||||
`Explain this configuration problem if invoked. Do not edit files or attempt fallback clones.`,
|
||||
].join("\n\n")
|
||||
}
|
||||
|
||||
return [
|
||||
`You are configured reference @${reference.name}, a read-only research agent for external reference material.`,
|
||||
`Repository: ${reference.repository}`,
|
||||
...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
|
||||
`Cached directory: ${reference.path}`,
|
||||
`OpenCode materializes this configured repository before use. Do not call repo_clone for this reference.`,
|
||||
`Inspect the cached directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches, then use Glob, Grep, and Read inside that directory. Do not edit files.`,
|
||||
`Return exact absolute file paths for findings whenever possible.`,
|
||||
].join("\n\n")
|
||||
}
|
||||
|
||||
function referenceDescription(reference: Reference.Resolved) {
|
||||
if (reference.kind === "local") return `Scout reference for local directory ${reference.path}`
|
||||
if (reference.kind === "git") return `Scout reference for repository ${reference.repository}`
|
||||
return `Invalid Scout reference for repository ${reference.repository}`
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_SCOUT) {
|
||||
const resolvedReferences = Reference.resolveAll({
|
||||
references: cfg.reference ?? {},
|
||||
directory: ctx.directory,
|
||||
worktree: ctx.worktree,
|
||||
})
|
||||
for (const resolved of resolvedReferences) {
|
||||
if (agents[resolved.name]) continue
|
||||
const localPath = resolved.kind === "invalid" ? undefined : resolved.path
|
||||
agents[resolved.name] = {
|
||||
name: resolved.name,
|
||||
description: referenceDescription(resolved),
|
||||
permission: Permission.merge(
|
||||
agents.scout.permission,
|
||||
Permission.fromConfig({
|
||||
repo_clone: "deny",
|
||||
...(localPath
|
||||
? {
|
||||
external_directory: {
|
||||
[localPath]: "allow",
|
||||
[path.join(localPath, "*")]: "allow",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
),
|
||||
prompt: referencePrompt(resolved),
|
||||
options: { reference: cfg.reference?.[resolved.name], resolved },
|
||||
mode: "subagent",
|
||||
native: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure Truncate.GLOB is allowed unless explicitly configured
|
||||
for (const name in agents) {
|
||||
const agent = agents[name]
|
||||
|
||||
@@ -93,7 +93,6 @@ const appBindingCommands = [
|
||||
"theme.mode.lock",
|
||||
"help.show",
|
||||
"docs.open",
|
||||
"app.exit",
|
||||
"app.debug",
|
||||
"app.console",
|
||||
"app.heap_snapshot",
|
||||
@@ -648,11 +647,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
title: "Exit the app",
|
||||
slashName: "exit",
|
||||
slashAliases: ["quit", "q"],
|
||||
enabled: () => {
|
||||
const current = promptRef.current
|
||||
if (!current?.focused) return true
|
||||
return current.current.input === ""
|
||||
},
|
||||
run: () => exit(),
|
||||
category: "System",
|
||||
},
|
||||
@@ -785,6 +779,17 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
enabled: () => {
|
||||
const ok = command.matcher.get()
|
||||
if (!ok) return false
|
||||
const current = promptRef.current
|
||||
if (!current?.focused) return true
|
||||
return current.current.input === ""
|
||||
},
|
||||
bindings: tuiConfig.keybinds.gather("app_exit", ["app.exit"]),
|
||||
}))
|
||||
|
||||
event.on(TuiEvent.CommandExecute.type, (evt) => {
|
||||
command.run(evt.properties.command)
|
||||
})
|
||||
|
||||
@@ -712,7 +712,6 @@ export function Prompt(props: PromptProps) {
|
||||
...input.traits,
|
||||
...computePromptTraits({
|
||||
mode: store.mode,
|
||||
disabled: !!props.disabled,
|
||||
autocompleteVisible: !!auto()?.visible,
|
||||
}),
|
||||
}
|
||||
@@ -927,13 +926,7 @@ export function Prompt(props: PromptProps) {
|
||||
target: inputTarget,
|
||||
enabled: (() => {
|
||||
cursorVersion()
|
||||
return (
|
||||
inputTarget() !== undefined &&
|
||||
!props.disabled &&
|
||||
!auto()?.visible &&
|
||||
input !== undefined &&
|
||||
(input.cursorOffset === 0 || input.visualCursor.visualRow === 0)
|
||||
)
|
||||
return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined
|
||||
})(),
|
||||
commands: [
|
||||
{
|
||||
@@ -942,12 +935,12 @@ export function Prompt(props: PromptProps) {
|
||||
category: "Prompt",
|
||||
run() {
|
||||
if (input.cursorOffset !== 0) {
|
||||
input.cursorOffset = 0
|
||||
return
|
||||
if (input.scrollY + input.visualCursor.visualRow === 0) input.cursorOffset = 0
|
||||
return false
|
||||
}
|
||||
|
||||
const item = history.move(-1, input.plainText)
|
||||
if (!item) return
|
||||
if (!item) return false
|
||||
input.setText(item.input)
|
||||
setStore("prompt", item)
|
||||
setStore("mode", item.mode ?? "normal")
|
||||
@@ -965,13 +958,7 @@ export function Prompt(props: PromptProps) {
|
||||
target: inputTarget,
|
||||
enabled: (() => {
|
||||
cursorVersion()
|
||||
return (
|
||||
inputTarget() !== undefined &&
|
||||
!props.disabled &&
|
||||
!auto()?.visible &&
|
||||
input !== undefined &&
|
||||
(input.cursorOffset === input.plainText.length || input.visualCursor.visualRow === input.height - 1)
|
||||
)
|
||||
return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined
|
||||
})(),
|
||||
commands: [
|
||||
{
|
||||
@@ -980,12 +967,16 @@ export function Prompt(props: PromptProps) {
|
||||
category: "Prompt",
|
||||
run() {
|
||||
if (input.cursorOffset !== input.plainText.length) {
|
||||
input.cursorOffset = input.plainText.length
|
||||
return
|
||||
if (
|
||||
input.scrollY + input.visualCursor.visualRow ===
|
||||
Math.max(0, input.editorView.getTotalVirtualLineCount() - 1)
|
||||
)
|
||||
input.cursorOffset = input.plainText.length
|
||||
return false
|
||||
}
|
||||
|
||||
const item = history.move(1, input.plainText)
|
||||
if (!item) return
|
||||
if (!item) return false
|
||||
input.setText(item.input)
|
||||
setStore("prompt", item)
|
||||
setStore("mode", item.mode ?? "normal")
|
||||
|
||||
@@ -4,7 +4,6 @@ export type PromptMode = "normal" | "shell"
|
||||
|
||||
export interface PromptTraitsInput {
|
||||
mode: PromptMode
|
||||
disabled: boolean
|
||||
autocompleteVisible: boolean
|
||||
}
|
||||
|
||||
@@ -16,10 +15,9 @@ export type PromptTraits = EditorTraits & {
|
||||
/**
|
||||
* Compute the textarea editor traits for the prompt.
|
||||
*
|
||||
* `traits.suspend` gates the textarea's keybinding actions (backspace,
|
||||
* delete-word, arrow movement, undo/redo, etc.). Shell mode is an active
|
||||
* editing mode — only `disabled` should suspend the textarea, otherwise
|
||||
* users can type in shell mode but cannot delete or move the cursor.
|
||||
* The OpenTUI managed textarea keymap owns `traits.suspend`. Prompt traits
|
||||
* only expose capture/status metadata so focus changes cannot unsuspend the
|
||||
* keymap-managed editor mappings.
|
||||
*/
|
||||
export function computePromptTraits(input: PromptTraitsInput): PromptTraits {
|
||||
const capture =
|
||||
@@ -30,7 +28,6 @@ export function computePromptTraits(input: PromptTraitsInput): PromptTraits {
|
||||
: undefined
|
||||
return {
|
||||
capture,
|
||||
suspend: input.disabled,
|
||||
status: input.mode === "shell" ? "SHELL" : undefined,
|
||||
owner: "opencode",
|
||||
role: "prompt",
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
import {
|
||||
KeymapProvider,
|
||||
reactiveMatcherFromSignal,
|
||||
useBindings,
|
||||
useKeymap,
|
||||
useKeymapSelector,
|
||||
useBindings,
|
||||
} from "@opentui/keymap/solid"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { TuiConfig } from "./config/tui"
|
||||
@@ -26,6 +26,28 @@ export { reactiveMatcherFromSignal, useBindings, useKeymapSelector }
|
||||
|
||||
export type OpenTuiKeymap = ReturnType<typeof useKeymap>
|
||||
|
||||
const KEY_ALIASES = {
|
||||
enter: "return",
|
||||
esc: "escape",
|
||||
} as const
|
||||
|
||||
function expandKeyAliases(input: string) {
|
||||
const result = Object.entries(KEY_ALIASES).reduce(
|
||||
(acc, [alias, key]) => acc.replace(new RegExp(`(^|[+,\\s>])${alias}(?=$|[+,\\s<])`, "gi"), `$1${key}`),
|
||||
input,
|
||||
)
|
||||
if (result === input) return
|
||||
return result
|
||||
}
|
||||
|
||||
function registerKeyAliases(keymap: OpenTuiKeymap) {
|
||||
return keymap.appendBindingExpander((ctx) => {
|
||||
const key = expandKeyAliases(ctx.input)
|
||||
if (!key) return
|
||||
return [{ key, displays: ctx.displays }]
|
||||
})
|
||||
}
|
||||
|
||||
const inputCommands = [
|
||||
"input.move.left",
|
||||
"input.move.right",
|
||||
@@ -98,8 +120,13 @@ export function formatKeyBindings(
|
||||
return formatCommandBindingsExtra(bindings, formatOptions(config))
|
||||
}
|
||||
|
||||
export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRenderer, config: TuiConfig.Resolved) {
|
||||
export function registerOpencodeKeymap(
|
||||
keymap: OpenTuiKeymap,
|
||||
renderer: CliRenderer,
|
||||
config: Pick<TuiConfig.Resolved, "keybinds" | "leader_timeout">,
|
||||
) {
|
||||
const offCommaBindings = addons.registerCommaBindings(keymap)
|
||||
const offAliasExpander = registerKeyAliases(keymap)
|
||||
const offBaseLayout = addons.registerBaseLayoutFallback(keymap)
|
||||
const offLeader = addons.registerTimedLeader(keymap, {
|
||||
trigger: config.keybinds.get(LEADER_TOKEN),
|
||||
@@ -108,20 +135,17 @@ export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRende
|
||||
})
|
||||
const offEscape = addons.registerEscapeClearsPendingSequence(keymap)
|
||||
const offBackspace = addons.registerBackspacePopsPendingSequence(keymap)
|
||||
const offInputCommands = addons.registerEditBufferCommands(keymap, renderer)
|
||||
const offInputSuspension = addons.registerTextareaMappingSuspension(keymap, renderer)
|
||||
const offInputBindings = keymap.registerLayer({
|
||||
const offInputBindings = addons.registerManagedTextareaLayer(keymap, renderer, {
|
||||
enabled: () => renderer.currentFocusedEditor !== null,
|
||||
bindings: config.keybinds.gather("input", inputCommands),
|
||||
})
|
||||
|
||||
return () => {
|
||||
offInputBindings()
|
||||
offInputSuspension()
|
||||
offInputCommands()
|
||||
offBackspace()
|
||||
offEscape()
|
||||
offLeader()
|
||||
offAliasExpander()
|
||||
offBaseLayout()
|
||||
offCommaBindings()
|
||||
}
|
||||
|
||||
@@ -746,7 +746,7 @@ export function Session() {
|
||||
title: "Line up",
|
||||
value: "session.line.up",
|
||||
category: "Session",
|
||||
enabled: false,
|
||||
hidden: true,
|
||||
run: () => {
|
||||
scroll.scrollBy(-1)
|
||||
dialog.clear()
|
||||
@@ -756,7 +756,7 @@ export function Session() {
|
||||
title: "Line down",
|
||||
value: "session.line.down",
|
||||
category: "Session",
|
||||
enabled: false,
|
||||
hidden: true,
|
||||
run: () => {
|
||||
scroll.scrollBy(1)
|
||||
dialog.clear()
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { Schema } from "effect"
|
||||
|
||||
const decodeSessionID = Schema.decodeUnknownSync(SessionID)
|
||||
|
||||
export async function validateSession(input: {
|
||||
url: string
|
||||
@@ -10,9 +13,11 @@ export async function validateSession(input: {
|
||||
}) {
|
||||
if (!input.sessionID) return
|
||||
|
||||
const result = SessionID.zod.safeParse(input.sessionID)
|
||||
if (!result.success) {
|
||||
throw new Error(`Invalid session ID: ${result.error.issues.at(0)?.message ?? "unknown error"}`)
|
||||
let sessionID: SessionID
|
||||
try {
|
||||
sessionID = decodeSessionID(input.sessionID)
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid session ID: ${error instanceof Error ? error.message : "unknown error"}`, { cause: error })
|
||||
}
|
||||
|
||||
await createOpencodeClient({
|
||||
@@ -20,5 +25,5 @@ export async function validateSession(input: {
|
||||
directory: input.directory,
|
||||
fetch: input.fetch,
|
||||
headers: input.headers,
|
||||
}).session.get({ sessionID: result.data }, { throwOnError: true })
|
||||
}).session.get({ sessionID }, { throwOnError: true })
|
||||
}
|
||||
|
||||
@@ -273,10 +273,10 @@ export const Info = Schema.Struct({
|
||||
}),
|
||||
tail_turns: Schema.optional(NonNegativeInt).annotate({
|
||||
description:
|
||||
"Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)",
|
||||
"Number of recent user turns, including their following assistant/tool responses, to serialize into the compaction summary (default: 2)",
|
||||
}),
|
||||
preserve_recent_tokens: Schema.optional(NonNegativeInt).annotate({
|
||||
description: "Maximum number of tokens from recent turns to preserve verbatim after compaction",
|
||||
description: "Maximum number of tokens from recent turns to serialize into the compaction summary",
|
||||
}),
|
||||
reserved: Schema.optional(NonNegativeInt).annotate({
|
||||
description: "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
import { Identifier } from "@/id/id"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const workspaceIdSchema = Schema.String.check(Schema.isStartsWith("wrk")).pipe(Schema.brand("WorkspaceID"))
|
||||
@@ -11,6 +10,5 @@ export type WorkspaceID = typeof workspaceIdSchema.Type
|
||||
export const WorkspaceID = workspaceIdSchema.pipe(
|
||||
withStatics((schema: typeof workspaceIdSchema) => ({
|
||||
ascending: (id?: string) => schema.make(Identifier.ascending("workspace", id)),
|
||||
zod: zod(schema),
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -641,7 +641,7 @@ export const layer = Layer.effect(
|
||||
|
||||
// "claim" this session so any future events coming from
|
||||
// the old workspace are ignored
|
||||
SyncEvent.claim(input.sessionID, input.workspaceID ?? previous.projectID)
|
||||
yield* sync.claim(input.sessionID, input.workspaceID ?? previous.projectID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -676,14 +676,12 @@ export const layer = Layer.effect(
|
||||
}
|
||||
|
||||
if (input.workspaceID === null) {
|
||||
yield* Effect.sync(() =>
|
||||
SyncEvent.run(Session.Event.Updated, {
|
||||
sessionID: input.sessionID,
|
||||
info: {
|
||||
workspaceID: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* sync.run(Session.Event.Updated, {
|
||||
sessionID: input.sessionID,
|
||||
info: {
|
||||
workspaceID: null,
|
||||
},
|
||||
})
|
||||
|
||||
log.info("session warp complete", {
|
||||
workspaceID: input.workspaceID,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||
|
||||
export const DataMigrationTable = sqliteTable("data_migration", {
|
||||
name: text().primaryKey(),
|
||||
time_completed: integer().notNull(),
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Database } from "./storage/db"
|
||||
import { DataMigrationTable } from "./data-migration.sql"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
export type Migration<R = never> = {
|
||||
name: string
|
||||
run: Effect.Effect<void, unknown, R>
|
||||
}
|
||||
|
||||
const log = Log.create({ service: "data-migration" })
|
||||
|
||||
export interface Interface {}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/DataMigration") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const migrations: Migration[] = []
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
if (migrations.length === 0) return
|
||||
|
||||
// Migrations run in a background fiber, so they must be resumable until
|
||||
// their completion row is written.
|
||||
for (const migration of migrations) {
|
||||
const completed = Database.use((db) =>
|
||||
db
|
||||
.select({ name: DataMigrationTable.name })
|
||||
.from(DataMigrationTable)
|
||||
.where(eq(DataMigrationTable.name, migration.name))
|
||||
.get(),
|
||||
)
|
||||
if (completed) continue
|
||||
|
||||
log.info("running data migration", { name: migration.name })
|
||||
yield* migration.run.pipe(Effect.withSpan("DataMigration", { attributes: { name: migration.name } }))
|
||||
Database.use((db) =>
|
||||
db
|
||||
.insert(DataMigrationTable)
|
||||
.values({ name: migration.name, time_completed: Date.now() })
|
||||
.onConflictDoNothing()
|
||||
.run(),
|
||||
)
|
||||
}
|
||||
}).pipe(
|
||||
Effect.tapCause((cause) => Effect.logError("failed to run data migrations", { cause })),
|
||||
Effect.ignore,
|
||||
Effect.forkScoped,
|
||||
)
|
||||
return Service.of({})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
||||
export * as DataMigration from "./data-migration"
|
||||
@@ -54,6 +54,7 @@ import { SessionShare } from "@/share/session"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||
import { DataMigration } from "@/data-migration"
|
||||
|
||||
export const AppLayer = Layer.mergeAll(
|
||||
Npm.defaultLayer,
|
||||
@@ -106,6 +107,7 @@ export const AppLayer = Layer.mergeAll(
|
||||
ShareNext.defaultLayer,
|
||||
SessionShare.defaultLayer,
|
||||
SyncEvent.defaultLayer,
|
||||
DataMigration.defaultLayer,
|
||||
).pipe(Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer))
|
||||
|
||||
const rt = ManagedRuntime.make(AppLayer, { memoMap })
|
||||
|
||||
@@ -86,7 +86,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
|
||||
)
|
||||
|
||||
const disposeContext = Effect.fn("InstanceStore.disposeContext")(function* (ctx: InstanceContext) {
|
||||
yield* Effect.logInfo("disposing instance", { directory: ctx.directory })
|
||||
yield* Effect.logInfo("disposing instance").pipe(Effect.annotateLogs("directory", ctx.directory))
|
||||
yield* Effect.promise(() => runDisposers(ctx.directory))
|
||||
yield* emitDisposed({ directory: ctx.directory, project: ctx.project.id })
|
||||
})
|
||||
@@ -109,7 +109,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
|
||||
const entry: Entry = { deferred: Deferred.makeUnsafe<InstanceContext>() }
|
||||
cache.set(directory, entry)
|
||||
yield* Effect.gen(function* () {
|
||||
yield* Effect.logInfo("creating instance", { directory })
|
||||
yield* Effect.logInfo("creating instance").pipe(Effect.annotateLogs("directory", directory))
|
||||
yield* completeLoad(directory, input, entry)
|
||||
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
|
||||
return yield* restore(Deferred.await(entry.deferred))
|
||||
@@ -125,7 +125,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
|
||||
const entry: Entry = { deferred: Deferred.makeUnsafe<InstanceContext>() }
|
||||
cache.set(directory, entry)
|
||||
yield* Effect.gen(function* () {
|
||||
yield* Effect.logInfo("reloading instance", { directory })
|
||||
yield* Effect.logInfo("reloading instance").pipe(Effect.annotateLogs("directory", directory))
|
||||
if (previous) {
|
||||
yield* Deferred.await(previous.deferred).pipe(Effect.ignore)
|
||||
yield* Effect.promise(() => runDisposers(directory))
|
||||
|
||||
@@ -34,7 +34,7 @@ const ProjectIcon = Schema.Struct({
|
||||
|
||||
const ProjectCommands = Schema.Struct({
|
||||
start: optionalOmitUndefined(
|
||||
Schema.String.annotate({ description: "Startup script to run when creating a new workspace (worktree)" }),
|
||||
Schema.String.annotate({ description: "Startup script to run when creating a new worktree" }),
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { TuiApi } from "./groups/tui"
|
||||
import { WorkspaceApi } from "./groups/workspace"
|
||||
import { V2Api } from "./groups/v2"
|
||||
import { Authorization } from "./middleware/authorization"
|
||||
import { SchemaErrorMiddleware } from "./middleware/schema-error"
|
||||
|
||||
// SSE event schemas built from the BusEvent/SyncEvent registries.
|
||||
const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" })
|
||||
@@ -29,6 +30,7 @@ const SyncEventSchemas = SyncEvent.effectPayloads()
|
||||
export const RootHttpApi = HttpApi.make("opencode-root")
|
||||
.addHttpApi(ControlApi)
|
||||
.addHttpApi(GlobalApi)
|
||||
.middleware(SchemaErrorMiddleware)
|
||||
.middleware(Authorization)
|
||||
|
||||
export const InstanceHttpApi = HttpApi.make("opencode-instance")
|
||||
@@ -47,6 +49,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
|
||||
.addHttpApi(V2Api)
|
||||
.addHttpApi(TuiApi)
|
||||
.addHttpApi(WorkspaceApi)
|
||||
.middleware(SchemaErrorMiddleware)
|
||||
|
||||
export const OpenCodeHttpApi = HttpApi.make("opencode")
|
||||
.addHttpApi(RootHttpApi)
|
||||
|
||||
@@ -236,9 +236,9 @@ export const SessionApi = HttpApi.make("session")
|
||||
HttpApiEndpoint.post("fork", SessionPaths.fork, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: ForkPayload,
|
||||
payload: Schema.optional(ForkPayload),
|
||||
success: described(Session.Info, "200"),
|
||||
error: ApiNotFoundError,
|
||||
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.fork",
|
||||
|
||||
@@ -187,13 +187,30 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||
|
||||
const fork = Effect.fn("SessionHttpApi.fork")(function* (ctx: {
|
||||
params: { sessionID: SessionID }
|
||||
payload: typeof ForkPayload.Type
|
||||
payload?: typeof ForkPayload.Type
|
||||
}) {
|
||||
return yield* SessionError.mapStorageNotFound(
|
||||
session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }),
|
||||
session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload?.messageID }),
|
||||
)
|
||||
})
|
||||
|
||||
const forkRaw = Effect.fn("SessionHttpApi.forkRaw")(function* (ctx: {
|
||||
params: { sessionID: SessionID }
|
||||
request: HttpServerRequest.HttpServerRequest
|
||||
}) {
|
||||
const body = yield* Effect.orDie(ctx.request.text)
|
||||
if (body.trim().length === 0) return yield* fork({ params: ctx.params })
|
||||
|
||||
const json = yield* Effect.try({
|
||||
try: () => JSON.parse(body) as unknown,
|
||||
catch: () => new HttpApiError.BadRequest({}),
|
||||
})
|
||||
const payload = yield* Schema.decodeUnknownEffect(ForkPayload)(json).pipe(
|
||||
Effect.mapError(() => new HttpApiError.BadRequest({})),
|
||||
)
|
||||
return yield* fork({ params: ctx.params, payload })
|
||||
})
|
||||
|
||||
const abort = Effect.fn("SessionHttpApi.abort")(function* (ctx: { params: { sessionID: SessionID } }) {
|
||||
yield* promptSvc.cancel(ctx.params.sessionID)
|
||||
return true
|
||||
@@ -373,7 +390,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||
.handleRaw("create", createRaw)
|
||||
.handle("remove", remove)
|
||||
.handle("update", update)
|
||||
.handle("fork", fork)
|
||||
.handleRaw("fork", forkRaw)
|
||||
.handle("abort", abort)
|
||||
.handle("init", init)
|
||||
.handle("share", share)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Effect } from "effect"
|
||||
import { HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
// Effect's Issue formatter recursively dumps the rejected `actual` value with
|
||||
// no truncation, so a 5KB invalid array produces a ~360KB string. Cap to keep
|
||||
// 4xx responses small and avoid mirroring entire request payloads (which may
|
||||
// contain secrets) into the response body and log file.
|
||||
const REASON_LIMIT = 1024
|
||||
function truncateReason(reason: string) {
|
||||
if (reason.length <= REASON_LIMIT) return reason
|
||||
return reason.slice(0, REASON_LIMIT) + `… (${reason.length - REASON_LIMIT} more chars)`
|
||||
}
|
||||
|
||||
// Default Respondable returns an empty 400 body. Match the NamedError shape
|
||||
// used by other 4xx/5xx so the SDK's `wrapClientError` extracts `.data.message`.
|
||||
export class SchemaErrorMiddleware extends HttpApiMiddleware.Service<SchemaErrorMiddleware>()(
|
||||
"@opencode/HttpApiSchemaError",
|
||||
) {}
|
||||
|
||||
export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrorMiddleware, (error) => {
|
||||
const reason = truncateReason(error.cause.message)
|
||||
log.warn("schema rejection", { kind: error.kind, reason })
|
||||
return Effect.succeed(
|
||||
HttpServerResponse.jsonUnsafe({ name: "BadRequest", data: { message: reason, kind: error.kind } }, { status: 400 }),
|
||||
)
|
||||
})
|
||||
@@ -178,17 +178,20 @@ function addLegacyErrorSchemas(spec: OpenApiSpec) {
|
||||
if (!spec.components?.schemas) return
|
||||
spec.components.schemas.BadRequestError = {
|
||||
type: "object",
|
||||
required: ["data", "errors", "success"],
|
||||
required: ["name", "data"],
|
||||
properties: {
|
||||
data: {},
|
||||
errors: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: {},
|
||||
name: { type: "string", enum: ["BadRequest"] },
|
||||
data: {
|
||||
type: "object",
|
||||
required: ["message"],
|
||||
properties: {
|
||||
message: { type: "string" },
|
||||
kind: {
|
||||
type: "string",
|
||||
enum: ["Params", "Headers", "Query", "Body", "Payload"],
|
||||
},
|
||||
},
|
||||
},
|
||||
success: { type: "boolean", enum: [false] },
|
||||
},
|
||||
}
|
||||
spec.components.schemas.NotFoundError = {
|
||||
|
||||
@@ -84,6 +84,7 @@ import { compressionLayer } from "./middleware/compression"
|
||||
import { corsVaryFix } from "./middleware/cors-vary"
|
||||
import { errorLayer } from "./middleware/error"
|
||||
import { fenceLayer } from "./middleware/fence"
|
||||
import { schemaErrorLayer } from "./middleware/schema-error"
|
||||
|
||||
export const context = Context.makeUnsafe<unknown>(new Map())
|
||||
|
||||
@@ -114,6 +115,7 @@ const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provi
|
||||
const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
|
||||
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(
|
||||
Layer.provide([controlHandlers, globalHandlers]),
|
||||
Layer.provide(schemaErrorLayer),
|
||||
Layer.provide(httpApiAuthLayer),
|
||||
)
|
||||
const instanceRouterLayer = authorizationRouterMiddleware
|
||||
@@ -150,6 +152,7 @@ const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe
|
||||
httpApiAuthLayer,
|
||||
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
|
||||
instanceContextLayer,
|
||||
schemaErrorLayer,
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import * as Session from "./session"
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import z from "zod"
|
||||
import { Token } from "@/util/token"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { SessionProcessor } from "./processor"
|
||||
@@ -18,9 +17,10 @@ import * as DateTime from "effect/DateTime"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { isOverflow as overflow, usable } from "./overflow"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { fn } from "@/util/fn"
|
||||
import { EventV2 } from "@/v2/event"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { SessionEvent } from "@/v2/session-event"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
|
||||
const log = Log.create({ service: "session.compaction" })
|
||||
|
||||
@@ -79,12 +79,10 @@ Rules:
|
||||
type Turn = {
|
||||
start: number
|
||||
end: number
|
||||
id: MessageID
|
||||
}
|
||||
|
||||
type Tail = {
|
||||
start: number
|
||||
id: MessageID
|
||||
}
|
||||
|
||||
type CompletedCompaction = {
|
||||
@@ -121,19 +119,41 @@ function completedCompactions(messages: MessageV2.WithParts[]) {
|
||||
})
|
||||
}
|
||||
|
||||
function buildPrompt(input: { previousSummary?: string; context: string[] }) {
|
||||
function buildPrompt(input: { previousSummary?: string; context: string[]; tail?: string }) {
|
||||
const source = input.tail
|
||||
? "the conversation history above and the serialized recent conversation tail below"
|
||||
: "the conversation history above"
|
||||
const anchor = input.previousSummary
|
||||
? [
|
||||
"Update the anchored summary below using the conversation history above.",
|
||||
`Update the anchored summary below using ${source}.`,
|
||||
"Preserve still-true details, remove stale details, and merge in the new facts.",
|
||||
"<previous-summary>",
|
||||
input.previousSummary,
|
||||
"</previous-summary>",
|
||||
].join("\n")
|
||||
: "Create a new anchored summary from the conversation history above."
|
||||
return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n")
|
||||
: `Create a new anchored summary from ${source}.`
|
||||
const tail = input.tail
|
||||
? [
|
||||
"Fold this serialized recent conversation tail into the summary; it is not provider message history.",
|
||||
"<recent-conversation-tail>",
|
||||
input.tail,
|
||||
"</recent-conversation-tail>",
|
||||
].join("\n")
|
||||
: undefined
|
||||
return [anchor, ...(tail ? [tail] : []), SUMMARY_TEMPLATE, ...input.context].join("\n\n")
|
||||
}
|
||||
|
||||
const serialize = Effect.fn("SessionCompaction.serialize")(function* (input: {
|
||||
messages: MessageV2.WithParts[]
|
||||
model: Provider.Model
|
||||
}) {
|
||||
const messages = yield* MessageV2.toModelMessagesEffect(input.messages, input.model, {
|
||||
stripMedia: true,
|
||||
toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS,
|
||||
})
|
||||
return messages.length ? JSON.stringify(messages, null, 2) : undefined
|
||||
})
|
||||
|
||||
function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model }) {
|
||||
return (
|
||||
input.cfg.compaction?.preserve_recent_tokens ??
|
||||
@@ -150,7 +170,6 @@ function turns(messages: MessageV2.WithParts[]) {
|
||||
result.push({
|
||||
start: i,
|
||||
end: messages.length,
|
||||
id: msg.info.id,
|
||||
})
|
||||
}
|
||||
for (let i = 0; i < result.length - 1; i++) {
|
||||
@@ -177,7 +196,6 @@ function splitTurn(input: {
|
||||
if (size > input.budget) continue
|
||||
return {
|
||||
start,
|
||||
id: input.messages[start]!.info.id,
|
||||
} satisfies Tail
|
||||
}
|
||||
return undefined
|
||||
@@ -208,6 +226,8 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionCompaction") {}
|
||||
|
||||
export const use = serviceUse(Service)
|
||||
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
@@ -218,6 +238,7 @@ export const layer: Layer.Layer<
|
||||
| Plugin.Service
|
||||
| SessionProcessor.Service
|
||||
| Provider.Service
|
||||
| SyncEvent.Service
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -228,6 +249,7 @@ export const layer: Layer.Layer<
|
||||
const plugin = yield* Plugin.Service
|
||||
const processors = yield* SessionProcessor.Service
|
||||
const provider = yield* Provider.Service
|
||||
const sync = yield* SyncEvent.Service
|
||||
|
||||
const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: {
|
||||
tokens: MessageV2.Assistant["tokens"]
|
||||
@@ -240,8 +262,7 @@ export const layer: Layer.Layer<
|
||||
messages: MessageV2.WithParts[]
|
||||
model: Provider.Model
|
||||
}) {
|
||||
const msgs = yield* MessageV2.toModelMessagesEffect(input.messages, input.model)
|
||||
return Token.estimate(JSON.stringify(msgs))
|
||||
return Token.estimate((yield* serialize(input)) ?? "")
|
||||
})
|
||||
|
||||
const select = Effect.fn("SessionCompaction.select")(function* (input: {
|
||||
@@ -250,10 +271,10 @@ export const layer: Layer.Layer<
|
||||
model: Provider.Model
|
||||
}) {
|
||||
const limit = input.cfg.compaction?.tail_turns ?? DEFAULT_TAIL_TURNS
|
||||
if (limit <= 0) return { head: input.messages, tail_start_id: undefined }
|
||||
if (limit <= 0) return { head: input.messages, tail: [] }
|
||||
const budget = preserveRecentBudget({ cfg: input.cfg, model: input.model })
|
||||
const all = turns(input.messages)
|
||||
if (!all.length) return { head: input.messages, tail_start_id: undefined }
|
||||
if (!all.length) return { head: input.messages, tail: [] }
|
||||
const recent = all.slice(-limit)
|
||||
const sizes = yield* Effect.forEach(
|
||||
recent,
|
||||
@@ -272,7 +293,7 @@ export const layer: Layer.Layer<
|
||||
const size = sizes[i]
|
||||
if (total + size <= budget) {
|
||||
total += size
|
||||
keep = { start: turn.start, id: turn.id }
|
||||
keep = { start: turn.start }
|
||||
continue
|
||||
}
|
||||
const remaining = budget - total
|
||||
@@ -288,10 +309,10 @@ export const layer: Layer.Layer<
|
||||
break
|
||||
}
|
||||
|
||||
if (!keep || keep.start === 0) return { head: input.messages, tail_start_id: undefined }
|
||||
if (!keep) return { head: input.messages, tail: [] }
|
||||
return {
|
||||
head: input.messages.slice(0, keep.start),
|
||||
tail_start_id: keep.id,
|
||||
tail: input.messages.slice(keep.start),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -402,7 +423,10 @@ export const layer: Layer.Layer<
|
||||
{ sessionID: input.sessionID },
|
||||
{ context: [], prompt: undefined },
|
||||
)
|
||||
const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context })
|
||||
const tailMessages = structuredClone(selected.tail)
|
||||
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: tailMessages })
|
||||
const tail = yield* serialize({ messages: tailMessages, model })
|
||||
const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context, tail })
|
||||
const msgs = structuredClone(selected.head)
|
||||
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
||||
const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, {
|
||||
@@ -469,13 +493,6 @@ export const layer: Layer.Layer<
|
||||
return "stop"
|
||||
}
|
||||
|
||||
if (compactionPart && selected.tail_start_id && compactionPart.tail_start_id !== selected.tail_start_id) {
|
||||
yield* session.updatePart({
|
||||
...compactionPart,
|
||||
tail_start_id: selected.tail_start_id,
|
||||
})
|
||||
}
|
||||
|
||||
if (result === "continue" && input.auto) {
|
||||
if (replay) {
|
||||
const original = replay.info
|
||||
@@ -566,12 +583,13 @@ export const layer: Layer.Layer<
|
||||
parts: [],
|
||||
},
|
||||
)
|
||||
EventV2.run(SessionEvent.Compaction.Ended.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
text: summary ?? "",
|
||||
include: selected.tail_start_id,
|
||||
})
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
|
||||
yield* sync.run(SessionEvent.Compaction.Ended.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
text: summary ?? "",
|
||||
})
|
||||
}
|
||||
yield* bus.publish(Event.Compacted, { sessionID: input.sessionID })
|
||||
}
|
||||
return result
|
||||
@@ -600,11 +618,13 @@ export const layer: Layer.Layer<
|
||||
auto: input.auto,
|
||||
overflow: input.overflow,
|
||||
})
|
||||
EventV2.run(SessionEvent.Compaction.Started.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
reason: input.auto ? "auto" : "manual",
|
||||
})
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
|
||||
yield* sync.run(SessionEvent.Compaction.Started.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
reason: input.auto ? "auto" : "manual",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
@@ -625,6 +645,7 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(SyncEvent.defaultLayer),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -638,15 +659,4 @@ export async function prune(input: { sessionID: SessionID }) {
|
||||
return runPromise((svc) => svc.prune(input))
|
||||
}
|
||||
|
||||
export const create = fn(
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
agent: z.string(),
|
||||
model: z.object({ providerID: ProviderID.zod, modelID: ModelID.zod }),
|
||||
auto: z.boolean(),
|
||||
overflow: z.boolean().optional(),
|
||||
}),
|
||||
(input) => runPromise((svc) => svc.create(input)),
|
||||
)
|
||||
|
||||
export * as SessionCompaction from "./compaction"
|
||||
|
||||
@@ -23,7 +23,7 @@ import type { SystemError } from "bun"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { Effect, Schema, Types } from "effect"
|
||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema"
|
||||
import { namedSchemaError } from "@/util/named-schema-error"
|
||||
import * as EffectLogger from "@opencode-ai/core/effect/logger"
|
||||
@@ -402,7 +402,7 @@ export const User = Schema.Struct({
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type User = Types.DeepMutable<Schema.Schema.Type<typeof User>>
|
||||
|
||||
const _Part = Schema.Union([
|
||||
export const Part = Schema.Union([
|
||||
TextPart,
|
||||
SubtaskPart,
|
||||
ReasoningPart,
|
||||
@@ -416,22 +416,6 @@ const _Part = Schema.Union([
|
||||
RetryPart,
|
||||
CompactionPart,
|
||||
]).annotate({ discriminator: "type", identifier: "Part" })
|
||||
export const Part = Object.assign(_Part, {
|
||||
zod: zod(_Part) as unknown as z.ZodType<
|
||||
| TextPart
|
||||
| SubtaskPart
|
||||
| ReasoningPart
|
||||
| FilePart
|
||||
| ToolPart
|
||||
| StepStartPart
|
||||
| StepFinishPart
|
||||
| SnapshotPart
|
||||
| PatchPart
|
||||
| AgentPart
|
||||
| RetryPart
|
||||
| CompactionPart
|
||||
>,
|
||||
})
|
||||
export type Part =
|
||||
| TextPart
|
||||
| SubtaskPart
|
||||
@@ -573,15 +557,12 @@ export type Assistant = Omit<Types.DeepMutable<Schema.Schema.Type<typeof Assista
|
||||
error?: AssistantError
|
||||
}
|
||||
|
||||
const _Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "Message" })
|
||||
export const Info = Object.assign(_Info, {
|
||||
zod: zod(_Info) as unknown as z.ZodType<User | Assistant>,
|
||||
})
|
||||
export const Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "Message" })
|
||||
export type Info = User | Assistant
|
||||
|
||||
const UpdatedEventSchema = Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
info: _Info,
|
||||
info: Info,
|
||||
})
|
||||
|
||||
const RemovedEventSchema = Schema.Struct({
|
||||
@@ -591,7 +572,7 @@ const RemovedEventSchema = Schema.Struct({
|
||||
|
||||
const PartUpdatedEventSchema = Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
part: _Part,
|
||||
part: Part,
|
||||
time: NonNegativeInt,
|
||||
})
|
||||
|
||||
@@ -639,8 +620,8 @@ export const Event = {
|
||||
}
|
||||
|
||||
export const WithParts = Schema.Struct({
|
||||
info: _Info,
|
||||
parts: Schema.Array(_Part),
|
||||
info: Info,
|
||||
parts: Schema.Array(Part),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type WithParts = {
|
||||
info: Info
|
||||
@@ -859,12 +840,13 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
|
||||
return part.metadata?.anthropic?.signature != null
|
||||
})
|
||||
for (const part of msg.parts) {
|
||||
if (msg.info.summary && part.type !== "text") continue
|
||||
if (part.type === "text") {
|
||||
const text = part.text === "" && hasSignedReasoning ? " " : part.text
|
||||
assistantMessage.parts.push({
|
||||
type: "text",
|
||||
text,
|
||||
...(differentModel ? {} : { providerMetadata: part.metadata }),
|
||||
...(differentModel || msg.info.summary ? {} : { providerMetadata: part.metadata }),
|
||||
})
|
||||
}
|
||||
if (part.type === "step-start")
|
||||
@@ -1090,53 +1072,16 @@ export function get(input: { sessionID: SessionID; messageID: MessageID }): With
|
||||
export function filterCompacted(msgs: Iterable<WithParts>) {
|
||||
const result = [] as WithParts[]
|
||||
const completed = new Set<string>()
|
||||
let retain: MessageID | undefined
|
||||
for (const msg of msgs) {
|
||||
result.push(msg)
|
||||
if (retain) {
|
||||
if (msg.info.id === retain) break
|
||||
continue
|
||||
}
|
||||
if (msg.info.role === "user" && completed.has(msg.info.id)) {
|
||||
const part = msg.parts.find((item): item is CompactionPart => item.type === "compaction")
|
||||
if (!part) continue
|
||||
if (!part.tail_start_id) break
|
||||
retain = part.tail_start_id
|
||||
if (msg.info.id === retain) break
|
||||
if (msg.parts.some((item): item is CompactionPart => item.type === "compaction")) break
|
||||
continue
|
||||
}
|
||||
if (msg.info.role === "user" && completed.has(msg.info.id) && msg.parts.some((part) => part.type === "compaction"))
|
||||
break
|
||||
if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish && !msg.info.error)
|
||||
completed.add(msg.info.parentID)
|
||||
}
|
||||
result.reverse()
|
||||
const compactionIndex = result.findLastIndex(
|
||||
(msg) =>
|
||||
msg.info.role === "user" &&
|
||||
msg.parts.some((item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined),
|
||||
)
|
||||
const compaction = result[compactionIndex]
|
||||
const part = compaction?.parts.find(
|
||||
(item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined,
|
||||
)
|
||||
const summaryIndex = compaction
|
||||
? result.findIndex(
|
||||
(msg, index) =>
|
||||
index > compactionIndex &&
|
||||
msg.info.role === "assistant" &&
|
||||
msg.info.summary &&
|
||||
msg.info.parentID === compaction.info.id,
|
||||
)
|
||||
: -1
|
||||
const tailIndex = part?.tail_start_id ? result.findIndex((msg) => msg.info.id === part.tail_start_id) : -1
|
||||
if (tailIndex >= 0 && tailIndex < compactionIndex && summaryIndex > compactionIndex) {
|
||||
return [
|
||||
...result.slice(compactionIndex, summaryIndex + 1),
|
||||
...result.slice(tailIndex, compactionIndex),
|
||||
...result.slice(summaryIndex + 1),
|
||||
]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -21,10 +21,11 @@ import { Question } from "@/question"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { EventV2 } from "@/v2/event"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { SessionEvent } from "@/v2/session-event"
|
||||
import { Modelv2 } from "@/v2/model"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
|
||||
const DOOM_LOOP_THRESHOLD = 3
|
||||
const log = Log.create({ service: "session.processor" })
|
||||
@@ -96,6 +97,7 @@ export const layer: Layer.Layer<
|
||||
| Image.Service
|
||||
| SessionSummary.Service
|
||||
| SessionStatus.Service
|
||||
| SyncEvent.Service
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -111,6 +113,7 @@ export const layer: Layer.Layer<
|
||||
const scope = yield* Scope.Scope
|
||||
const status = yield* SessionStatus.Service
|
||||
const image = yield* Image.Service
|
||||
const sync = yield* SyncEvent.Service
|
||||
|
||||
const create = Effect.fn("SessionProcessor.create")(function* (input: Input) {
|
||||
// Pre-capture snapshot before the LLM stream starts. The AI SDK
|
||||
@@ -229,11 +232,13 @@ export const layer: Layer.Layer<
|
||||
case "reasoning-start":
|
||||
if (value.id in ctx.reasoningMap) return
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Reasoning.Started.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
reasoningID: value.id,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
|
||||
yield* sync.run(SessionEvent.Reasoning.Started.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
reasoningID: value.id,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
ctx.reasoningMap[value.id] = {
|
||||
id: PartID.ascending(),
|
||||
messageID: ctx.assistantMessage.id,
|
||||
@@ -262,12 +267,14 @@ export const layer: Layer.Layer<
|
||||
case "reasoning-end":
|
||||
if (!(value.id in ctx.reasoningMap)) return
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Reasoning.Ended.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
reasoningID: value.id,
|
||||
text: ctx.reasoningMap[value.id].text,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
|
||||
yield* sync.run(SessionEvent.Reasoning.Ended.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
reasoningID: value.id,
|
||||
text: ctx.reasoningMap[value.id].text,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
// oxlint-disable-next-line no-self-assign -- reactivity trigger
|
||||
ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text
|
||||
ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() }
|
||||
@@ -281,12 +288,14 @@ export const layer: Layer.Layer<
|
||||
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
|
||||
}
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Tool.Input.Started.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.id,
|
||||
name: value.toolName,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
|
||||
yield* sync.run(SessionEvent.Tool.Input.Started.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.id,
|
||||
name: value.toolName,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
const part = yield* session.updatePart({
|
||||
id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(),
|
||||
messageID: ctx.assistantMessage.id,
|
||||
@@ -310,12 +319,14 @@ export const layer: Layer.Layer<
|
||||
|
||||
case "tool-input-end": {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Tool.Input.Ended.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.id,
|
||||
text: "",
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
|
||||
yield* sync.run(SessionEvent.Tool.Input.Ended.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.id,
|
||||
text: "",
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -325,17 +336,19 @@ export const layer: Layer.Layer<
|
||||
}
|
||||
const toolCall = yield* readToolCall(value.toolCallId)
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Tool.Called.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.toolCallId,
|
||||
tool: value.toolName,
|
||||
input: value.input,
|
||||
provider: {
|
||||
executed: toolCall?.part.metadata?.providerExecuted === true,
|
||||
...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
|
||||
yield* sync.run(SessionEvent.Tool.Called.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.toolCallId,
|
||||
tool: value.toolName,
|
||||
input: value.input,
|
||||
provider: {
|
||||
executed: toolCall?.part.metadata?.providerExecuted === true,
|
||||
...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
yield* updateToolCall(value.toolCallId, (match) => ({
|
||||
...match,
|
||||
tool: value.toolName,
|
||||
@@ -405,27 +418,29 @@ export const layer: Layer.Layer<
|
||||
attachments: attachments?.length ? attachments : undefined,
|
||||
}
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Tool.Success.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.toolCallId,
|
||||
structured: output.metadata,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: output.output,
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
|
||||
yield* sync.run(SessionEvent.Tool.Success.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.toolCallId,
|
||||
structured: output.metadata,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: output.output,
|
||||
},
|
||||
...(output.attachments?.map((item: MessageV2.FilePart) => ({
|
||||
type: "file",
|
||||
uri: item.url,
|
||||
mime: item.mime,
|
||||
name: item.filename,
|
||||
})) ?? []),
|
||||
],
|
||||
provider: {
|
||||
executed: toolCall?.part.metadata?.providerExecuted === true,
|
||||
},
|
||||
...(output.attachments?.map((item: MessageV2.FilePart) => ({
|
||||
type: "file",
|
||||
uri: item.url,
|
||||
mime: item.mime,
|
||||
name: item.filename,
|
||||
})) ?? []),
|
||||
],
|
||||
provider: {
|
||||
executed: toolCall?.part.metadata?.providerExecuted === true,
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
yield* completeToolCall(value.toolCallId, output)
|
||||
return
|
||||
}
|
||||
@@ -433,18 +448,20 @@ export const layer: Layer.Layer<
|
||||
case "tool-error": {
|
||||
const toolCall = yield* readToolCall(value.toolCallId)
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Tool.Failed.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.toolCallId,
|
||||
error: {
|
||||
type: "unknown",
|
||||
message: errorMessage(value.error),
|
||||
},
|
||||
provider: {
|
||||
executed: toolCall?.part.metadata?.providerExecuted === true,
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
|
||||
yield* sync.run(SessionEvent.Tool.Failed.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.toolCallId,
|
||||
error: {
|
||||
type: "unknown",
|
||||
message: errorMessage(value.error),
|
||||
},
|
||||
provider: {
|
||||
executed: toolCall?.part.metadata?.providerExecuted === true,
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
yield* failToolCall(value.toolCallId, value.error)
|
||||
return
|
||||
}
|
||||
@@ -456,17 +473,19 @@ export const layer: Layer.Layer<
|
||||
if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Step.Started.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
agent: input.assistantMessage.agent,
|
||||
model: {
|
||||
id: Modelv2.ID.make(ctx.model.id),
|
||||
providerID: Modelv2.ProviderID.make(ctx.model.providerID),
|
||||
variant: Modelv2.VariantID.make(input.assistantMessage.variant ?? "default"),
|
||||
},
|
||||
snapshot: ctx.snapshot,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
|
||||
yield* sync.run(SessionEvent.Step.Started.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
agent: input.assistantMessage.agent,
|
||||
model: {
|
||||
id: Modelv2.ID.make(ctx.model.id),
|
||||
providerID: Modelv2.ProviderID.make(ctx.model.providerID),
|
||||
variant: Modelv2.VariantID.make(input.assistantMessage.variant ?? "default"),
|
||||
},
|
||||
snapshot: ctx.snapshot,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
}
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
@@ -486,14 +505,16 @@ export const layer: Layer.Layer<
|
||||
})
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Step.Ended.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
finish: value.finishReason,
|
||||
cost: usage.cost,
|
||||
tokens: usage.tokens,
|
||||
snapshot: completedSnapshot,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
|
||||
yield* sync.run(SessionEvent.Step.Ended.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
finish: value.finishReason,
|
||||
cost: usage.cost,
|
||||
tokens: usage.tokens,
|
||||
snapshot: completedSnapshot,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
}
|
||||
ctx.assistantMessage.finish = value.finishReason
|
||||
ctx.assistantMessage.cost += usage.cost
|
||||
@@ -541,10 +562,12 @@ export const layer: Layer.Layer<
|
||||
case "text-start":
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Text.Started.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
|
||||
yield* sync.run(SessionEvent.Text.Started.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
}
|
||||
ctx.currentText = {
|
||||
id: PartID.ascending(),
|
||||
@@ -586,11 +609,13 @@ export const layer: Layer.Layer<
|
||||
)).text
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Text.Ended.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
text: ctx.currentText.text,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
|
||||
yield* sync.run(SessionEvent.Text.Ended.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
text: ctx.currentText.text,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
}
|
||||
{
|
||||
const end = Date.now()
|
||||
@@ -680,14 +705,16 @@ export const layer: Layer.Layer<
|
||||
}
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Step.Failed.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
error: {
|
||||
type: "unknown",
|
||||
message: errorMessage(e),
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
|
||||
yield* sync.run(SessionEvent.Step.Failed.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
error: {
|
||||
type: "unknown",
|
||||
message: errorMessage(e),
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
}
|
||||
ctx.assistantMessage.error = error
|
||||
yield* bus.publish(Session.Event.Error, {
|
||||
@@ -732,22 +759,28 @@ export const layer: Layer.Layer<
|
||||
parse,
|
||||
set: (info) => {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Retried.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
attempt: info.attempt,
|
||||
error: {
|
||||
message: info.message,
|
||||
isRetryable: true,
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
return status.set(ctx.sessionID, {
|
||||
type: "retry",
|
||||
attempt: info.attempt,
|
||||
message: info.message,
|
||||
action: info.action,
|
||||
next: info.next,
|
||||
})
|
||||
const event = Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM
|
||||
? sync.run(SessionEvent.Retried.Sync, {
|
||||
sessionID: ctx.sessionID,
|
||||
attempt: info.attempt,
|
||||
error: {
|
||||
message: info.message,
|
||||
isRetryable: true,
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
: Effect.void
|
||||
return event.pipe(
|
||||
Effect.andThen(
|
||||
status.set(ctx.sessionID, {
|
||||
type: "retry",
|
||||
attempt: info.attempt,
|
||||
message: info.message,
|
||||
action: info.action,
|
||||
next: info.next,
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
}),
|
||||
),
|
||||
@@ -788,6 +821,7 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
Layer.provide(Image.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(SyncEvent.defaultLayer),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@ import { Shell } from "@/shell/shell"
|
||||
import { ShellID } from "@/tool/shell/id"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { Image } from "@/image/image"
|
||||
import { decodeDataUrl } from "@/util/data-url"
|
||||
import { Process } from "@/util/process"
|
||||
import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema, Types } from "effect"
|
||||
@@ -54,10 +53,11 @@ import { InstanceState } from "@/effect/instance-state"
|
||||
import { TaskTool, type TaskPromptOps } from "@/tool/task"
|
||||
import { SessionRunState } from "./run-state"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { EventV2 } from "@/v2/event"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { SessionEvent } from "@/v2/session-event"
|
||||
import { Modelv2 } from "@/v2/model"
|
||||
import { AgentAttachment, FileAttachment, Source } from "@/v2/session-prompt"
|
||||
import { AgentAttachment, FileAttachment, ReferenceAttachment, Source } from "@/v2/session-prompt"
|
||||
import { Reference } from "@/reference/reference"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import { eq } from "@/storage/db"
|
||||
import * as Database from "@/storage/db"
|
||||
@@ -66,6 +66,9 @@ import { SessionTable } from "./session.sql"
|
||||
// @ts-ignore
|
||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
|
||||
const decodeMessageInfo = Schema.decodeUnknownExit(MessageV2.Info)
|
||||
const decodeMessagePart = Schema.decodeUnknownExit(MessageV2.Part)
|
||||
|
||||
const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested structured format.
|
||||
|
||||
IMPORTANT:
|
||||
@@ -79,12 +82,51 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc
|
||||
const log = Log.create({ service: "session.prompt" })
|
||||
const elog = EffectLogger.create({ service: "session.prompt" })
|
||||
|
||||
type ReferencePromptMetadata = {
|
||||
name: string
|
||||
kind: "local" | "git" | "invalid"
|
||||
path?: string
|
||||
repository?: string
|
||||
branch?: string
|
||||
target?: string
|
||||
targetPath?: string
|
||||
problem?: string
|
||||
source: { value: string; start: number; end: number }
|
||||
}
|
||||
|
||||
function stringField(record: Record<string, unknown>, key: string) {
|
||||
return typeof record[key] === "string" ? record[key] : undefined
|
||||
}
|
||||
|
||||
function referencePromptMetadata(input: unknown): ReferencePromptMetadata | undefined {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) return
|
||||
const record = input as Record<string, unknown>
|
||||
const name = stringField(record, "name")
|
||||
const kind = stringField(record, "kind")
|
||||
if (!name || (kind !== "local" && kind !== "git" && kind !== "invalid")) return
|
||||
if (!record.source || typeof record.source !== "object" || Array.isArray(record.source)) return
|
||||
const source = record.source as Record<string, unknown>
|
||||
const value = stringField(source, "value")
|
||||
if (!value || typeof source.start !== "number" || typeof source.end !== "number") return
|
||||
return {
|
||||
name,
|
||||
kind,
|
||||
path: stringField(record, "path"),
|
||||
repository: stringField(record, "repository"),
|
||||
branch: stringField(record, "branch"),
|
||||
target: stringField(record, "target"),
|
||||
targetPath: stringField(record, "targetPath"),
|
||||
problem: stringField(record, "problem"),
|
||||
source: { value, start: source.start, end: source.end },
|
||||
}
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
|
||||
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts, Image.Error>
|
||||
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts>
|
||||
readonly loop: (input: LoopInput) => Effect.Effect<MessageV2.WithParts>
|
||||
readonly shell: (input: ShellInput) => Effect.Effect<MessageV2.WithParts>
|
||||
readonly command: (input: CommandInput) => Effect.Effect<MessageV2.WithParts, Image.Error>
|
||||
readonly command: (input: CommandInput) => Effect.Effect<MessageV2.WithParts>
|
||||
readonly resolvePromptParts: (template: string) => Effect.Effect<PromptInput["parts"]>
|
||||
}
|
||||
|
||||
@@ -109,7 +151,6 @@ export const layer = Layer.effect(
|
||||
const lsp = yield* LSP.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const truncate = yield* Truncate.Service
|
||||
const image = yield* Image.Service
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const scope = yield* Scope.Scope
|
||||
const instruction = yield* Instruction.Service
|
||||
@@ -118,6 +159,8 @@ export const layer = Layer.effect(
|
||||
const summary = yield* SessionSummary.Service
|
||||
const sys = yield* SystemPrompt.Service
|
||||
const llm = yield* LLM.Service
|
||||
const references = yield* Reference.Service
|
||||
const sync = yield* SyncEvent.Service
|
||||
const runner = Effect.fn("SessionPrompt.runner")(function* () {
|
||||
return yield* EffectBridge.make()
|
||||
})
|
||||
@@ -125,7 +168,7 @@ export const layer = Layer.effect(
|
||||
return {
|
||||
cancel: (sessionID: SessionID) => cancel(sessionID),
|
||||
resolvePromptParts: (template: string) => resolvePromptParts(template),
|
||||
prompt: (input: PromptInput) => prompt(input).pipe(Effect.catch(Effect.die)),
|
||||
prompt: (input: PromptInput) => prompt(input),
|
||||
} satisfies TaskPromptOps
|
||||
})
|
||||
|
||||
@@ -139,12 +182,116 @@ export const layer = Layer.effect(
|
||||
const parts: Types.DeepMutable<PromptInput["parts"]> = [{ type: "text", text: template }]
|
||||
const files = ConfigMarkdown.files(template)
|
||||
const seen = new Set<string>()
|
||||
const mentionSource = (match: RegExpMatchArray) => {
|
||||
const start = match.index ?? 0
|
||||
return { value: match[0], start, end: start + match[0].length }
|
||||
}
|
||||
const referenceTextPart = (input: {
|
||||
reference: Reference.Resolved
|
||||
source: ReturnType<typeof mentionSource>
|
||||
target?: string
|
||||
targetPath?: string
|
||||
problem?: string
|
||||
}): MessageV2.TextPartInput => {
|
||||
const metadata: ReferencePromptMetadata = {
|
||||
name: input.reference.name,
|
||||
kind: input.reference.kind,
|
||||
...(input.reference.kind === "invalid"
|
||||
? { repository: input.reference.repository }
|
||||
: { path: input.reference.path }),
|
||||
...(input.reference.kind === "git"
|
||||
? { repository: input.reference.repository, branch: input.reference.branch }
|
||||
: {}),
|
||||
...(input.target === undefined ? {} : { target: input.target }),
|
||||
...(input.targetPath ? { targetPath: input.targetPath } : {}),
|
||||
problem: input.problem ?? (input.reference.kind === "invalid" ? input.reference.message : undefined),
|
||||
source: input.source,
|
||||
}
|
||||
const label = metadata.target === undefined ? `@${metadata.name}` : `@${metadata.name}/${metadata.target}`
|
||||
return {
|
||||
type: "text",
|
||||
synthetic: true,
|
||||
text: [
|
||||
`Referenced configured reference ${label}.`,
|
||||
...(metadata.kind === "local" ? ["Kind: local directory"] : []),
|
||||
...(metadata.kind === "git" ? ["Kind: git repository"] : []),
|
||||
...(metadata.repository ? [`Repository: ${metadata.repository}`] : []),
|
||||
...(metadata.branch ? [`Branch/ref: ${metadata.branch}`] : []),
|
||||
...(metadata.path ? [`Reference root: ${metadata.path}`] : []),
|
||||
...(metadata.targetPath ? [`Resolved path: ${metadata.targetPath}`] : []),
|
||||
...(metadata.problem
|
||||
? [`Problem: ${metadata.problem}`]
|
||||
: [
|
||||
"For targeted context, inspect the reference path directly with Read, Glob, and Grep. For broader research, call the task tool with subagent scout and include this reference path.",
|
||||
]),
|
||||
].join("\n"),
|
||||
metadata: { reference: metadata },
|
||||
}
|
||||
}
|
||||
yield* Effect.forEach(
|
||||
files,
|
||||
Effect.fnUntraced(function* (match) {
|
||||
const name = match[1]
|
||||
if (!name) return
|
||||
if (seen.has(name)) return
|
||||
seen.add(name)
|
||||
|
||||
const slash = name.indexOf("/")
|
||||
const alias = slash === -1 ? name : name.slice(0, slash)
|
||||
const reference = yield* references.get(alias)
|
||||
if (reference) {
|
||||
const source = mentionSource(match)
|
||||
if (reference.kind === "invalid") {
|
||||
parts.push(
|
||||
referenceTextPart({ reference, source, target: slash === -1 ? undefined : name.slice(slash + 1) }),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
yield* references.ensure(reference.path)
|
||||
if (slash === -1) {
|
||||
parts.push(referenceTextPart({ reference, source }))
|
||||
return
|
||||
}
|
||||
|
||||
const target = name.slice(slash + 1)
|
||||
const targetPath = path.resolve(reference.path, target)
|
||||
if (!AppFileSystem.contains(reference.path, targetPath)) {
|
||||
parts.push(
|
||||
referenceTextPart({
|
||||
reference,
|
||||
source,
|
||||
target,
|
||||
targetPath,
|
||||
problem: `Path escapes configured reference @${alias}: ${target}`,
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const info = yield* fsys.stat(targetPath).pipe(Effect.option)
|
||||
if (Option.isNone(info)) {
|
||||
parts.push(
|
||||
referenceTextPart({
|
||||
reference,
|
||||
source,
|
||||
target,
|
||||
targetPath,
|
||||
problem: `Path does not exist inside configured reference @${alias}: ${target}`,
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
parts.push({
|
||||
type: "file",
|
||||
url: pathToFileURL(targetPath).href,
|
||||
filename: name,
|
||||
mime: info.value.type === "Directory" ? "application/x-directory" : "text/plain",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const filepath = name.startsWith("~/")
|
||||
? path.join(os.homedir(), name.slice(2))
|
||||
: path.resolve(ctx.worktree, name)
|
||||
@@ -758,7 +905,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
|
||||
throw error
|
||||
}
|
||||
const model = input.model ?? agent.model ?? (yield* lastModel(input.sessionID))
|
||||
const model = input.model ?? agent.model ?? (yield* currentModel(input.sessionID))
|
||||
const userMsg: MessageV2.User = {
|
||||
id: input.messageID ?? MessageID.ascending(),
|
||||
sessionID: input.sessionID,
|
||||
@@ -809,12 +956,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
},
|
||||
}
|
||||
yield* sessions.updatePart(part)
|
||||
EventV2.run(SessionEvent.Shell.Started.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(started),
|
||||
callID,
|
||||
command: input.command,
|
||||
})
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
|
||||
yield* sync.run(SessionEvent.Shell.Started.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(started),
|
||||
callID,
|
||||
command: input.command,
|
||||
})
|
||||
}
|
||||
return { msg, part, cwd: ctx.directory }
|
||||
}).pipe(Effect.ensuring(markReady))
|
||||
|
||||
@@ -830,12 +979,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
output += "\n\n" + ["<metadata>", "User aborted the command", "</metadata>"].join("\n")
|
||||
}
|
||||
const completed = Date.now()
|
||||
EventV2.run(SessionEvent.Shell.Ended.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(completed),
|
||||
callID: part.callID,
|
||||
output,
|
||||
})
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
|
||||
yield* sync.run(SessionEvent.Shell.Ended.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(completed),
|
||||
callID: part.callID,
|
||||
output,
|
||||
})
|
||||
}
|
||||
if (!msg.time.completed) {
|
||||
msg.time.completed = completed
|
||||
yield* sessions.updateMessage(msg)
|
||||
@@ -916,7 +1067,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
return yield* Effect.failCause(exit.cause)
|
||||
})
|
||||
|
||||
const lastModel = Effect.fnUntraced(function* (sessionID: SessionID) {
|
||||
const currentModel = Effect.fnUntraced(function* (sessionID: SessionID) {
|
||||
const current = Database.use((db) =>
|
||||
db.select({ model: SessionTable.model }).from(SessionTable).where(eq(SessionTable.id, sessionID)).get(),
|
||||
)
|
||||
if (current?.model) {
|
||||
return {
|
||||
providerID: ProviderID.make(current.model.providerID),
|
||||
modelID: ModelID.make(current.model.id),
|
||||
...(current.model.variant && current.model.variant !== "default" ? { variant: current.model.variant } : {}),
|
||||
}
|
||||
}
|
||||
const match = yield* sessions.findMessage(sessionID, (m) => m.info.role === "user" && !!m.info.model)
|
||||
if (Option.isSome(match) && match.value.info.role === "user") return match.value.info.model
|
||||
return yield* provider.defaultModel()
|
||||
@@ -933,7 +1094,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
throw error
|
||||
}
|
||||
|
||||
const model = input.model ?? ag.model ?? (yield* lastModel(input.sessionID))
|
||||
const current = Database.use((db) =>
|
||||
db
|
||||
.select({ agent: SessionTable.agent, model: SessionTable.model })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, input.sessionID))
|
||||
.get(),
|
||||
)
|
||||
const model = input.model ?? ag.model ?? (yield* currentModel(input.sessionID))
|
||||
const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID
|
||||
const full =
|
||||
!input.variant && ag.variant && same
|
||||
@@ -957,15 +1125,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
format: input.format,
|
||||
}
|
||||
|
||||
const current = Database.use((db) =>
|
||||
db
|
||||
.select({ agent: SessionTable.agent, model: SessionTable.model })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, input.sessionID))
|
||||
.get(),
|
||||
)
|
||||
if (current?.agent !== info.agent) {
|
||||
EventV2.run(SessionEvent.AgentSwitched.Sync, {
|
||||
yield* sync.run(SessionEvent.AgentSwitched.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
agent: info.agent,
|
||||
@@ -974,9 +1135,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
if (
|
||||
current?.model?.providerID !== info.model.providerID ||
|
||||
current.model.id !== info.model.modelID ||
|
||||
current.model.variant !== info.model.variant
|
||||
(current.model.variant === "default" ? undefined : current.model.variant) !== info.model.variant
|
||||
) {
|
||||
EventV2.run(SessionEvent.ModelSwitched.Sync, {
|
||||
yield* sync.run(SessionEvent.ModelSwitched.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
model: {
|
||||
@@ -1277,30 +1438,28 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
{ message: info, parts: resolvedParts },
|
||||
)
|
||||
|
||||
const parts = yield* Effect.forEach(resolvedParts, (part) =>
|
||||
part.type === "file" && part.mime.startsWith("image/") ? image.normalize(part) : Effect.succeed(part),
|
||||
)
|
||||
const parts = resolvedParts
|
||||
|
||||
const parsed = MessageV2.Info.zod.safeParse(info)
|
||||
if (!parsed.success) {
|
||||
const parsed = decodeMessageInfo(info, { errors: "all", propertyOrder: "original" })
|
||||
if (Exit.isFailure(parsed)) {
|
||||
log.error("invalid user message before save", {
|
||||
sessionID: input.sessionID,
|
||||
messageID: info.id,
|
||||
agent: info.agent,
|
||||
model: info.model,
|
||||
issues: parsed.error.issues,
|
||||
cause: Cause.pretty(parsed.cause),
|
||||
})
|
||||
}
|
||||
parts.forEach((part, index) => {
|
||||
const p = MessageV2.Part.zod.safeParse(part)
|
||||
if (p.success) return
|
||||
const p = decodeMessagePart(part, { errors: "all", propertyOrder: "original" })
|
||||
if (Exit.isSuccess(p)) return
|
||||
log.error("invalid user part before save", {
|
||||
sessionID: input.sessionID,
|
||||
messageID: info.id,
|
||||
partID: part.id,
|
||||
partType: part.type,
|
||||
index,
|
||||
issues: p.error.issues,
|
||||
cause: Cause.pretty(p.cause),
|
||||
part,
|
||||
})
|
||||
})
|
||||
@@ -1312,6 +1471,26 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
if (part.type === "text") {
|
||||
if (part.synthetic) result.synthetic.push(part.text)
|
||||
else result.text.push(part.text)
|
||||
const reference = referencePromptMetadata(part.metadata?.reference)
|
||||
if (reference) {
|
||||
result.references.push(
|
||||
new ReferenceAttachment({
|
||||
name: reference.name,
|
||||
kind: reference.kind,
|
||||
uri: reference.path ? pathToFileURL(reference.path).href : undefined,
|
||||
repository: reference.repository,
|
||||
branch: reference.branch,
|
||||
target: reference.target,
|
||||
targetUri: reference.targetPath ? pathToFileURL(reference.targetPath).href : undefined,
|
||||
problem: reference.problem,
|
||||
source: new Source({
|
||||
start: reference.source.start,
|
||||
end: reference.source.end,
|
||||
text: reference.source.value,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (part.type === "file") {
|
||||
result.files.push(
|
||||
@@ -1349,51 +1528,57 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
text: [] as string[],
|
||||
files: [] as FileAttachment[],
|
||||
agents: [] as AgentAttachment[],
|
||||
references: [] as ReferenceAttachment[],
|
||||
synthetic: [] as string[],
|
||||
},
|
||||
)
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Prompted.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
prompt: {
|
||||
text: nextPrompt.text.join("\n"),
|
||||
files: nextPrompt.files,
|
||||
agents: nextPrompt.agents,
|
||||
},
|
||||
})
|
||||
for (const text of nextPrompt.synthetic) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
EventV2.run(SessionEvent.Synthetic.Sync, {
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
|
||||
yield* sync.run(SessionEvent.Prompted.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
text,
|
||||
prompt: {
|
||||
text: nextPrompt.text.join("\n"),
|
||||
files: nextPrompt.files,
|
||||
agents: nextPrompt.agents,
|
||||
references: nextPrompt.references,
|
||||
},
|
||||
})
|
||||
}
|
||||
for (const text of nextPrompt.synthetic) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) {
|
||||
yield* sync.run(SessionEvent.Synthetic.Sync, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
text,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { info, parts }
|
||||
}, Effect.scoped)
|
||||
|
||||
const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts, Image.Error> = Effect.fn(
|
||||
"SessionPrompt.prompt",
|
||||
)(function* (input: PromptInput) {
|
||||
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
||||
yield* revert.cleanup(session)
|
||||
const message = yield* createUserMessage(input)
|
||||
yield* sessions.touch(input.sessionID)
|
||||
const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.prompt")(
|
||||
function* (input: PromptInput) {
|
||||
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
|
||||
yield* revert.cleanup(session)
|
||||
const message = yield* createUserMessage(input)
|
||||
yield* sessions.touch(input.sessionID)
|
||||
|
||||
const permissions: Permission.Ruleset = []
|
||||
for (const [t, enabled] of Object.entries(input.tools ?? {})) {
|
||||
permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })
|
||||
}
|
||||
if (permissions.length > 0) {
|
||||
session.permission = permissions
|
||||
yield* sessions.setPermission({ sessionID: session.id, permission: permissions })
|
||||
}
|
||||
const permissions: Permission.Ruleset = []
|
||||
for (const [t, enabled] of Object.entries(input.tools ?? {})) {
|
||||
permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })
|
||||
}
|
||||
if (permissions.length > 0) {
|
||||
session.permission = permissions
|
||||
yield* sessions.setPermission({ sessionID: session.id, permission: permissions })
|
||||
}
|
||||
|
||||
if (input.noReply === true) return message
|
||||
return yield* loop({ sessionID: input.sessionID })
|
||||
})
|
||||
if (input.noReply === true) return message
|
||||
return yield* loop({ sessionID: input.sessionID })
|
||||
},
|
||||
)
|
||||
|
||||
const lastAssistant = Effect.fnUntraced(function* (sessionID: SessionID) {
|
||||
const match = yield* sessions.findMessage(sessionID, (m) => m.info.role !== "user")
|
||||
@@ -1704,7 +1889,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
if (cmdAgent?.model) return cmdAgent.model
|
||||
}
|
||||
if (input.model) return Provider.parseModel(input.model)
|
||||
return yield* lastModel(input.sessionID)
|
||||
return yield* currentModel(input.sessionID)
|
||||
})
|
||||
|
||||
yield* getModel(taskModel.providerID, taskModel.modelID, input.sessionID)
|
||||
@@ -1737,7 +1922,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
const userModel = isSubtask
|
||||
? input.model
|
||||
? Provider.parseModel(input.model)
|
||||
: yield* lastModel(input.sessionID)
|
||||
: yield* currentModel(input.sessionID)
|
||||
: taskModel
|
||||
|
||||
yield* plugin.trigger(
|
||||
@@ -1794,14 +1979,15 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(SessionRevert.defaultLayer),
|
||||
Layer.provide(SessionSummary.defaultLayer),
|
||||
Layer.provide(Image.defaultLayer),
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
Agent.defaultLayer,
|
||||
SystemPrompt.defaultLayer,
|
||||
LLM.defaultLayer,
|
||||
Reference.defaultLayer,
|
||||
Bus.layer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
SyncEvent.defaultLayer,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
import { Identifier } from "@/id/id"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
export const SessionID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
|
||||
Schema.brand("SessionID"),
|
||||
withStatics((s) => ({
|
||||
descending: (id?: string) => s.make(Identifier.descending("session", id)),
|
||||
zod: zod(s),
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -18,7 +16,6 @@ export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe(
|
||||
Schema.brand("MessageID"),
|
||||
withStatics((s) => ({
|
||||
ascending: (id?: string) => s.make(Identifier.ascending("message", id)),
|
||||
zod: zod(s),
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -28,7 +25,6 @@ export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe(
|
||||
Schema.brand("PartID"),
|
||||
withStatics((s) => ({
|
||||
ascending: (id?: string) => s.make(Identifier.ascending("part", id)),
|
||||
zod: zod(s),
|
||||
})),
|
||||
)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user