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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,74 @@ describe("getSessionContext", () => {

expect(ctx).toBeUndefined()
})

test("prefers the provider-reported total over the token sum", () => {
const messages = [
{
id: "a1",
role: "assistant",
providerID: "openai",
modelID: "gpt-4.1",
cost: 0.5,
tokens: { total: 800, input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1 },
} as unknown as Message,
]
const providers = [{ id: "openai", models: { "gpt-4.1": { limit: { context: 1000 } } } }]

const ctx = getSessionContext(messages, providers)

expect(ctx?.message.id).toBe("a1")
expect(ctx?.total).toBe(800)
expect(ctx?.usage).toBe(80)
})

test("skips an interrupt estimate when a reported value exists", () => {
const messages = [
{
id: "reported",
role: "assistant",
providerID: "openai",
modelID: "gpt-4.1",
cost: 0.5,
tokens: { total: 800, input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1 },
} as unknown as Message,
{
id: "estimated",
role: "assistant",
providerID: "openai",
modelID: "gpt-4.1",
cost: 0,
error: { name: "MessageAbortedError" },
tokens: { input: 100, output: 10, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 2 },
} as unknown as Message,
]
const providers = [{ id: "openai", models: { "gpt-4.1": { limit: { context: 1000 } } } }]

const ctx = getSessionContext(messages, providers)

expect(ctx?.message.id).toBe("reported")
})

test("uses an interrupt estimate when no reported usage exists", () => {
const messages = [
{
id: "estimated",
role: "assistant",
providerID: "openai",
modelID: "gpt-4.1",
cost: 0,
error: { name: "MessageAbortedError" },
tokens: { input: 100, output: 10, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1 },
} as unknown as Message,
]
const providers = [{ id: "openai", models: { "gpt-4.1": { limit: { context: 1000 } } } }]

const ctx = getSessionContext(messages, providers)

expect(ctx?.message.id).toBe("estimated")
})
})
18 changes: 16 additions & 2 deletions packages/app/src/components/session/session-context-metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,30 @@ type Context = {
}

const tokenTotal = (msg: AssistantMessage) => {
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
return (
msg.tokens.total ??
msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
)
}

// Interrupted turns get locally estimated tokens (no provider-reported
// `total`); an estimate must never displace a provider-reported value.
const estimatedTokens = (msg: AssistantMessage) =>
msg.tokens.total === undefined && msg.error?.name === "MessageAbortedError"

// Latest assistant message with reported usage, last write wins — reported
// values move the meter in both directions (context shrinks when the provider
// compacts its own history); estimates only ever fill a void.
const lastAssistantWithTokens = (messages: Message[]) => {
let estimated: AssistantMessage | undefined
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.role !== "assistant") continue
if (tokenTotal(msg) <= 0) continue
return msg
if (!estimatedTokens(msg)) return msg
estimated ??= msg
}
return estimated
}

const build = (messages: Message[] = [], providers: Provider[] = []): Context | undefined => {
Expand Down
16 changes: 16 additions & 0 deletions packages/llm/src/schema/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,19 @@ export const StepFinish = Schema.Struct({
}).annotate({ identifier: "LLM.Event.StepFinish" })
export type StepFinish = Schema.Schema.Type<typeof StepFinish>

/**
* Mid-stream usage snapshot (ACP `usage_update` parity). Providers that
* report live context occupancy emit this as it changes; `usage` is a
* last-write-wins snapshot, not a delta — later events supersede earlier
* ones, and values may decrease (e.g. after the provider compacts its own
* context).
*/
export const UsageUpdate = Schema.Struct({
type: Schema.tag("usage"),
usage: Usage,
}).annotate({ identifier: "LLM.Event.UsageUpdate" })
export type UsageUpdate = Schema.Schema.Type<typeof UsageUpdate>

export const Finish = Schema.Struct({
type: Schema.tag("finish"),
reason: FinishReason,
Expand Down Expand Up @@ -221,6 +234,7 @@ const llmEventTagged = Schema.Union([
ToolResult,
ToolError,
StepFinish,
UsageUpdate,
Finish,
ProviderErrorEvent,
]).pipe(Schema.toTaggedUnion("type"))
Expand Down Expand Up @@ -267,6 +281,7 @@ export const LLMEvent = Object.assign(llmEventTagged, {
...input,
usage: input.usage === undefined ? undefined : Usage.from(input.usage),
}),
usage: (input: UsageInput) => UsageUpdate.make({ usage: Usage.from(input) }),
finish: (input: WithUsage<Finish>) =>
Finish.make({
...input,
Expand All @@ -288,6 +303,7 @@ export const LLMEvent = Object.assign(llmEventTagged, {
toolResult: llmEventTagged.guards["tool-result"],
toolError: llmEventTagged.guards["tool-error"],
stepFinish: llmEventTagged.guards["step-finish"],
usage: llmEventTagged.guards.usage,
finish: llmEventTagged.guards.finish,
providerError: llmEventTagged.guards["provider-error"],
},
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"dependencies": {
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/claude-agent-acp": "0.54.1",
"@agentclientprotocol/sdk": "0.21.0",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112",
Expand All @@ -74,6 +75,7 @@
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.102",
"@anthropic-ai/sdk": "0.93.0",
"@aws-sdk/credential-providers": "3.1057.0",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/opentelemetry": "catalog:",
Expand Down
45 changes: 39 additions & 6 deletions packages/opencode/src/acp/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -617,7 +617,11 @@ function makeUsageService(sdk: OpencodeClient) {
})
.catch(() => undefined)
limits.set(key, next)
return yield* Effect.promise(() => next)
const value = yield* Effect.promise(() => next)
// An unknown limit must not stick: evict the failed lookup so the next
// usage update retries instead of never reporting again.
if (value === undefined && limits.get(key) === next) limits.delete(key)
return value
},
)

Expand Down Expand Up @@ -796,18 +800,25 @@ function defaultModelFromConfig(
const opencodeModel = opencodeProvider ? Provider.sort(Object.values(opencodeProvider.models))[0] : undefined
if (opencodeProvider && opencodeModel) return { providerID: opencodeProvider.id, modelID: opencodeModel.id }

const best = Provider.sort(Object.values(providers).flatMap((provider) => Object.values(provider.models)))[0]
const best = Provider.sort(
automaticModels(Object.values(providers).flatMap((provider) => Object.values(provider.models))),
)[0]
if (best) return { providerID: best.providerID, modelID: best.id }
if (configured) return configured
}

function selectDefaultModel(snapshot: Directory.Snapshot) {
if (snapshot.defaultModel) return snapshot.defaultModel
const model = snapshot.modelOptions[0]
const model = automaticModels(snapshot.modelOptions)[0]
if (model) return { providerID: model.providerID, modelID: model.modelID }
return { providerID: "unknown" as ProviderV2.ID, modelID: "unknown" as ModelV2.ID }
}

function automaticModels<T extends { providerID: ProviderV2.ID }>(models: readonly T[]) {
const withoutClaudeACP = models.filter((model) => model.providerID !== Provider.ClaudeACPProviderID)
return withoutClaudeACP.length > 0 ? withoutClaudeACP : [...models]
}

function detectSlashCommand(parts: ReturnType<typeof promptContentToParts>) {
const text = parts
.filter((part): part is Extract<(typeof parts)[number], { type: "text" }> => part.type === "text")
Expand Down Expand Up @@ -877,6 +888,10 @@ function promptErrorMessage(error: AssistantError) {
return "OpenCode prompt failed"
}

// One usage service per SDK client so the context-limit cache survives across
// updates — rebuilding it per call would refetch providers on every update.
const usageServices = new WeakMap<OpencodeClient, UsageService.Interface>()

function sendUsageUpdate(
usage: UsageService.Interface | undefined,
sdk: OpencodeClient,
Expand All @@ -885,7 +900,12 @@ function sendUsageUpdate(
directory: string,
) {
if (!connection) return Effect.void
return (usage ?? makeUsageService(sdk)).sendUpdate({
let service = usage ?? usageServices.get(sdk)
if (!service) {
service = makeUsageService(sdk)
usageServices.set(sdk, service)
}
return service.sendUpdate({
connection,
sessionID,
directory,
Expand Down Expand Up @@ -1040,7 +1060,10 @@ function restoreFromMessages(messages: readonly MessageInfo[]) {
)
if (user?.model?.providerID && user.model.modelID) {
return {
model: { providerID: user.model.providerID as ProviderV2.ID, modelID: user.model.modelID as ModelV2.ID },
model: normalizeRestoredModel({
providerID: user.model.providerID as ProviderV2.ID,
modelID: user.model.modelID as ModelV2.ID,
}),
variant: user.model.variant,
modeId: user.agent,
}
Expand All @@ -1049,7 +1072,10 @@ function restoreFromMessages(messages: readonly MessageInfo[]) {
const assistant = messages.findLast((message) => message.providerID && message.modelID)
if (assistant?.providerID && assistant.modelID) {
return {
model: { providerID: assistant.providerID as ProviderV2.ID, modelID: assistant.modelID as ModelV2.ID },
model: normalizeRestoredModel({
providerID: assistant.providerID as ProviderV2.ID,
modelID: assistant.modelID as ModelV2.ID,
}),
variant: assistant.variant,
modeId: assistant.mode ?? assistant.agent,
}
Expand All @@ -1058,6 +1084,13 @@ function restoreFromMessages(messages: readonly MessageInfo[]) {
return {}
}

function normalizeRestoredModel(model: Directory.DefaultModel) {
if (model.providerID === Provider.ClaudeACPProviderID && model.modelID === ModelV2.ID.make("default")) {
return { providerID: Provider.ClaudeACPProviderID, modelID: Provider.ClaudeACPModelID }
}
return model
}

function isSdkResponse<T>(value: T | SdkResponse<T>): value is SdkResponse<T> {
return typeof value === "object" && value !== null && ("data" in value || "error" in value)
}
Expand Down
Loading
Loading