Skip to content
Merged
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ tagged release also ships native binaries for Linux, macOS, and Windows.

## Unreleased

- Managed model steps record the HTTP call and Ace hold that produced them, making each charge auditable.

- Settings → Usage no longer fails when a saved session or message record has no id; such records are skipped.

- Point the plotly and neuropixels-analysis skills at their `references/` guides; their links named a `reference/` folder that does not exist.

- Preserve distinct tokens at overlapping positions when reconstructing literature abstracts, without duplicating repeated postings or losing words at sparse positions.
Expand Down
49 changes: 49 additions & 0 deletions backend/cli/src/provider/call-link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/** Identity that ties one HTTP attempt to the Atlas hold that reserved it.
* The client mints the call id so failed or unanswered calls stay findable.
* The fetch wrapper stamps both onto the response it returns, so a step reads
* the identity of its own response rather than shared request state. */
export namespace CallLink {
export const CALL_HEADER = "x-openscience-call"
export const HOLD_HEADER = "x-openscience-hold-id"
const HOLD = /^(hold|orgh)_[A-Za-z0-9_]{1,80}$/
const CALL = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/

function validHold(value: string | null | undefined) {
return value && HOLD.test(value) ? value : undefined
}

export function hold(headers: Headers) {
return validHold(headers.get(HOLD_HEADER))
}

export function headers(
base: HeadersInit | undefined,
input: { call: string; sessionID: string; messageID: string; attempt: number },
) {
const result = new Headers(base)
result.set(CALL_HEADER, input.call)
result.set("x-openscience-message", input.messageID)
result.set("x-openscience-attempt", String(input.attempt))
result.set("x-openscience-session", input.sessionID)
return result
}

/** Response headers the wrapper returns: always this attempt's call id, and
* the hold only when it came from the managed gateway in a valid shape. */
export function responseHeaders(source: Headers, input: { call: string; hold?: string }) {
const result = new Headers(source)
result.set(CALL_HEADER, input.call)
if (input.hold) result.set(HOLD_HEADER, input.hold)
else result.delete(HOLD_HEADER)
return result
}

/** The call identity of one step, from the response headers the AI SDK
* attaches to that step (a lowercase record). */
export function fromResponse(headers: Record<string, string | undefined> | undefined) {
const id = headers?.[CALL_HEADER]
if (!id || !CALL.test(id)) return undefined
const hold = validHold(headers[HOLD_HEADER])
return hold ? { id, hold } : { id }
}
}
34 changes: 28 additions & 6 deletions backend/cli/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { AsyncLocalStorage } from "node:async_hooks"
import { MANAGED_OPENROUTER_MODEL_SET, managedModelDetails } from "./managed-catalog"
import { managedModelRoute } from "./managed-routing"
import { ManagedPricing } from "./managed-pricing"
import { CallLink } from "./call-link"
import { gatewayTiming, type GatewayTiming } from "./gateway-timing"

// Direct imports for bundled providers
Expand Down Expand Up @@ -395,11 +396,11 @@ export namespace Provider {
return "error"
}

function copyResponse(response: Response, body: ReadableStream<Uint8Array>) {
function copyResponse(response: Response, body: ReadableStream<Uint8Array> | null, headers: Headers) {
const monitored = new Response(body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
headers,
})
for (const property of ["url", "redirected", "type"] as const) {
Object.defineProperty(monitored, property, { configurable: true, value: response[property] })
Expand Down Expand Up @@ -487,12 +488,13 @@ export namespace Provider {
? undefined
: setTimeout(() => idleController.abort(new RequestTimeoutError("total", total)), total).unref()
const signal = signals.length === 1 ? signals[0]! : AbortSignal.any(signals)
const call = crypto.randomUUID()
const timing: Omit<RequestTiming, "completedAt" | "outcome"> = {
sessionID: context.sessionID,
messageID: context.messageID,
attempt: context.attempt,
...(context.agent && { agent: context.agent }),
requestID: crypto.randomUUID(),
requestID: call,
providerID: options.providerID,
modelID: requestModel(init?.body) ?? context.modelID ?? options.modelID,
idleTimeoutMs,
Expand Down Expand Up @@ -520,7 +522,16 @@ export namespace Provider {
try {
response = await waitForActivity({
run: () => {
const fetchInit = { ...(init ?? {}), signal }
const fetchInit = {
...(init ?? {}),
signal,
...(options.managed && {
headers: CallLink.headers(
init?.headers ?? (fetchInput instanceof Request ? fetchInput.headers : undefined),
{ call, sessionID: context.sessionID, messageID: context.messageID, attempt: context.attempt },
),
}),
}
// Bun's native fetch accepts this runtime option even though its
// current BunFetchRequestInit declaration omits it.
;(fetchInit as BunFetchRequestInit & { timeout: false }).timeout = false
Expand Down Expand Up @@ -555,13 +566,24 @@ export namespace Provider {
throw connectFailure(error, signal)
}

// The response carries its own call identity (and, on the managed route,
// the validated hold), so a step reads the attempt that produced it even
// when the SDK has already started the next step's fetch.
const linked = CallLink.responseHeaders(response.headers, {
call,
hold: options.managed ? CallLink.hold(response.headers) : undefined,
})
// Response.error()/opaque responses use status 0, which the Response
// constructor forbids. They do not expose a consumable network body, so
// preserve the original object rather than attempting to wrap it.
if (!response.body || response.status === 0) {
if (response.status === 0) {
emit("completed")
return response
}
if (!response.body) {
emit("completed")
return copyResponse(response, null, linked)
}

const reader = response.body.getReader()
let closed = false
Expand Down Expand Up @@ -624,7 +646,7 @@ export namespace Provider {
cancelReader(reason)
},
})
return copyResponse(response, body)
return copyResponse(response, body, linked)
}

// Models exposed by the ChatGPT / Codex OAuth transport. Keep the dot and
Expand Down
2 changes: 2 additions & 0 deletions backend/cli/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { Toolset } from "./toolset"
import { UsageLogging } from "./usage-logging"

import { ManagedPricing } from "@/provider/managed-pricing"
import { CallLink } from "@/provider/call-link"

export namespace LLM {
const log = Log.create({ service: "llm" })
Expand Down Expand Up @@ -331,6 +332,7 @@ export namespace LLM {
duration: performance.now() - started,
content: { parts: step.content, toolResults: step.toolResults },
finish: step.finishReason,
call: CallLink.fromResponse(step.response?.headers),
}).catch(() => l.warn("could not persist usage record"))
},
async onError(error) {
Expand Down
4 changes: 4 additions & 0 deletions backend/cli/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,10 @@ export namespace MessageV2 {
provider: z.string(),
model: z.string(),
time: z.number(),
/** Client-minted id of the HTTP attempt that produced this step. */
call: z.string().optional(),
/** Atlas hold that reserved this step's Ace charge (managed route only). */
hold: z.string().optional(),
})
.optional(),
/** Delivered speed when the provider reports it; otherwise the selected speed. */
Expand Down
9 changes: 9 additions & 0 deletions backend/cli/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type { CredentialSource } from "./access-route"
import { OpenScience } from "@/openscience"
import { BILLING_URL } from "@/endpoints"
import { ManagedPricing } from "@/provider/managed-pricing"
import { CallLink } from "@/provider/call-link"
import { SessionTraceStore } from "./trace-store"
import type { NamedError } from "@synsci/util/error"
import { ToolRetryGuard } from "./tool-retry-guard"
Expand Down Expand Up @@ -1230,6 +1231,10 @@ export namespace SessionProcessor {
}

case "finish-step":
// The call and hold travel on this step's own response: in
// a multi-step tool stream the SDK may already be fetching
// the next step, so shared request state would name it.
const stepCall = CallLink.fromResponse(value.response?.headers)
const funded = requiresWalletBalance(credentialSource)
const usage = Session.getUsage({
model: resolved.model,
Expand Down Expand Up @@ -1262,6 +1267,10 @@ export namespace SessionProcessor {
provider: resolved.model.providerID,
model: resolved.model.id,
time: Date.now(),
...(stepCall && {
call: stepCall.id,
...(stepCall.hold && { hold: stepCall.hold }),
}),
},
tier: usage.tier,
tokens: usage.tokens,
Expand Down
2 changes: 2 additions & 0 deletions backend/cli/src/session/usage-logging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,11 +244,13 @@ export namespace UsageLogging {
duration: number
content?: unknown
finish?: string
call?: { id: string; hold?: string }
},
) {
return event(input, "model.response", {
...reported(input.usage, input.metadata),
duration_ms: input.duration,
...(input.call && { http_call_id: input.call.id, ...(input.call.hold && { hold: input.call.hold }) }),
...(input.content === undefined ? {} : { content: input.content }),
...(input.finish === undefined ? {} : { finish: input.finish }),
})
Expand Down
5 changes: 3 additions & 2 deletions backend/cli/src/session/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ export async function localUsage(start: number, end: number): Promise<UsageRow[]
if (error instanceof Storage.NotFoundError) return undefined
throw error
})
if (!session || session.time.updated < start) continue
// A record without an id (legacy or corrupt) has no messages to list.
if (!session?.id || session.time.updated < start) continue
const keys = await Storage.list(["message", session.id])
for (let offset = 0; offset < keys.length; offset += 32) {
const messages = await Promise.all(
Expand All @@ -24,7 +25,7 @@ export async function localUsage(start: number, end: number): Promise<UsageRow[]
),
)
for (const message of messages) {
if (!message || message.role !== "assistant") continue
if (!message?.id || message.role !== "assistant") continue
// Forks copy their transcript. Only calls made after this session was
// created belong to it; inherited history must not be counted again.
if (message.time.created < session.time.created || message.time.created >= end) continue
Expand Down
155 changes: 155 additions & 0 deletions backend/cli/test/provider/call-link.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { describe, expect, test } from "bun:test"
import { CallLink } from "../../src/provider/call-link"
import { Provider } from "../../src/provider/provider"

const context = (): Provider.RequestContext => ({ sessionID: "ses_link", messageID: "msg_link", attempt: 2 })

async function send(input: {
managed: boolean
response?: Headers
request?: Request
ctx?: Provider.RequestContext
body?: string | null
}) {
const seen: Headers[] = []
const fetchFn = async (fetchInput: unknown, init?: RequestInit) => {
seen.push(new Headers(init?.headers ?? (fetchInput instanceof Request ? fetchInput.headers : undefined)))
return new Response(input.body === undefined ? "ok" : input.body, { headers: input.response })
}
const run = () =>
Provider.fetchWithIdleWatchdog(fetchFn, input.request ?? "https://gateway.test", input.request ? undefined : {}, {
providerID: "openrouter",
modelID: "fixture",
managed: input.managed,
})
const response = input.ctx ? await Provider.withRequestContext(input.ctx, run) : await run()
await response.text()
// The AI SDK hands a step its response headers as a lowercase record.
const link = CallLink.fromResponse(Object.fromEntries(response.headers.entries()))
return { sent: seen[0]!, link, response }
}

describe("call link", () => {
test("managed requests carry the call identity and the response carries the same call and a valid hold", async () => {
const result = await send({
managed: true,
ctx: context(),
response: new Headers({ "x-openscience-hold-id": "orgh_0123456789abcdef" }),
})
expect(result.sent.get("x-openscience-call")).toMatch(/^[0-9a-f-]{36}$/)
expect(result.sent.get("x-openscience-message")).toBe("msg_link")
expect(result.sent.get("x-openscience-attempt")).toBe("2")
expect(result.sent.get("x-openscience-session")).toBe("ses_link")
expect(result.link).toEqual({
id: result.sent.get("x-openscience-call")!,
hold: "orgh_0123456789abcdef",
})
})

test("a malformed hold id is dropped from the response", async () => {
const result = await send({
managed: true,
ctx: context(),
response: new Headers({ "x-openscience-hold-id": "Bearer secret" }),
})
expect(result.link?.id).toBeDefined()
expect(result.link?.hold).toBeUndefined()
expect(result.response.headers.get("x-openscience-hold-id")).toBeNull()
})

test("non-managed requests get a call id but send no link headers and keep no hold", async () => {
const result = await send({
managed: false,
ctx: context(),
response: new Headers({ "x-openscience-hold-id": "hold_0123456789abcdef" }),
})
expect(result.sent.get("x-openscience-call")).toBeNull()
expect(result.sent.get("x-openscience-message")).toBeNull()
expect(result.sent.get("x-openscience-attempt")).toBeNull()
expect(result.sent.get("x-openscience-session")).toBeNull()
expect(result.link?.id).toMatch(/^[0-9a-f-]{36}$/)
expect(result.link?.hold).toBeUndefined()
expect(result.response.headers.get("x-openscience-hold-id")).toBeNull()
})

test("an upstream cannot choose the call id", async () => {
const result = await send({
managed: true,
ctx: context(),
response: new Headers({ "x-openscience-call": "spoofed" }),
})
expect(result.link?.id).toBe(result.sent.get("x-openscience-call")!)
})

test("a response without a body still carries its call", async () => {
const result = await send({ managed: true, ctx: context(), body: null })
expect(result.link?.id).toBe(result.sent.get("x-openscience-call")!)
})

test("a Request input keeps unrelated headers and cannot override the request identity", async () => {
const request = new Request("https://gateway.test", {
method: "POST",
headers: {
authorization: "Bearer test-only",
"Idempotency-Key": "logical",
"x-openscience-call": "spoofed",
"x-openscience-message": "spoofed",
"x-openscience-attempt": "999",
"x-openscience-session": "spoofed",
},
body: "{}",
})
const result = await send({ managed: true, ctx: context(), request })
expect(result.sent.get("authorization")).toBe("Bearer test-only")
expect(result.sent.get("Idempotency-Key")).toBe("logical")
expect(result.sent.get("x-openscience-call")).toMatch(/^[0-9a-f-]{36}$/)
expect(result.sent.get("x-openscience-message")).toBe("msg_link")
expect(result.sent.get("x-openscience-attempt")).toBe("2")
expect(result.sent.get("x-openscience-session")).toBe("ses_link")
})

test("without a request context the response still carries its call and nothing throws", async () => {
const result = await send({ managed: true })
expect(result.sent.get("x-openscience-call")).not.toBeNull()
expect(result.link?.id).toBe(result.sent.get("x-openscience-call")!)
})

test("sequential attempts on one context each keep their own call and hold on their own response", async () => {
const ctx = context()
const first = await send({ managed: true, ctx, response: new Headers({ "x-openscience-hold-id": "hold_first" }) })
const second = await send({ managed: true, ctx, response: new Headers({ "x-openscience-hold-id": "hold_second" }) })
expect(first.sent.get("x-openscience-call")).not.toBe(second.sent.get("x-openscience-call"))
// The first response is unaffected by the later attempt on the same context.
expect(first.link).toEqual({ id: first.sent.get("x-openscience-call")!, hold: "hold_first" })
expect(second.link).toEqual({ id: second.sent.get("x-openscience-call")!, hold: "hold_second" })
expect(ctx).toEqual(context())
})

test("the call id is the timing request id", async () => {
const timings: Provider.RequestTiming[] = []
const off = Provider.onTiming((timing) => timings.push(timing))
const result = await send({ managed: true, ctx: context() })
off()
expect(timings.at(-1)?.requestID).toBe(result.sent.get("x-openscience-call")!)
})

test("hold() accepts only the two Atlas hold shapes", () => {
expect(CallLink.hold(new Headers({ "x-openscience-hold-id": "hold_abc123" }))).toBe("hold_abc123")
expect(CallLink.hold(new Headers({ "x-openscience-hold-id": "orgh_legacy_abc" }))).toBe("orgh_legacy_abc")
expect(CallLink.hold(new Headers({ "x-openscience-hold-id": "gen-123" }))).toBeUndefined()
expect(CallLink.hold(new Headers({ "x-openscience-hold-id": "hold_" + "a".repeat(81) }))).toBeUndefined()
expect(CallLink.hold(new Headers())).toBeUndefined()
})

test("fromResponse() reads a step's own response headers", () => {
const id = "7f3c1e9a-0000-4000-8000-000000000001"
expect(CallLink.fromResponse({ "x-openscience-call": id, "x-openscience-hold-id": "hold_abc" })).toEqual({
id,
hold: "hold_abc",
})
expect(CallLink.fromResponse({ "x-openscience-call": id, "x-openscience-hold-id": "nope" })).toEqual({ id })
expect(CallLink.fromResponse({ "x-openscience-call": "not a uuid" })).toBeUndefined()
expect(CallLink.fromResponse({})).toBeUndefined()
expect(CallLink.fromResponse(undefined)).toBeUndefined()
})
})
Loading
Loading