From 7bc29392feefc08ed6ac97d23eb0337da5409bc1 Mon Sep 17 00:00:00 2001 From: KB Date: Mon, 5 Oct 2026 09:21:27 +0530 Subject: [PATCH 1/7] feat(provider): mint a call id per attempt and capture the Ace hold --- backend/cli/src/provider/call-link.ts | 22 ++++ backend/cli/src/provider/provider.ts | 26 ++++- backend/cli/test/provider/call-link.test.ts | 101 ++++++++++++++++++ .../cli/test/provider/idle-watchdog.test.ts | 2 +- 4 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 backend/cli/src/provider/call-link.ts create mode 100644 backend/cli/test/provider/call-link.test.ts diff --git a/backend/cli/src/provider/call-link.ts b/backend/cli/src/provider/call-link.ts new file mode 100644 index 000000000..c756a7fb1 --- /dev/null +++ b/backend/cli/src/provider/call-link.ts @@ -0,0 +1,22 @@ +/** 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. */ +export namespace CallLink { + const HOLD = /^(hold|orgh)_[A-Za-z0-9_]{1,80}$/ + + export function hold(headers: Headers) { + const value = headers.get("x-openscience-hold-id") + return value && HOLD.test(value) ? value : undefined + } + + export function headers( + base: HeadersInit | undefined, + input: { call: string; sessionID: string; messageID: string; attempt: number }, + ) { + const result = new Headers(base) + result.set("x-openscience-call", input.call) + result.set("x-openscience-message", input.messageID) + result.set("x-openscience-attempt", String(input.attempt)) + if (!result.has("x-openscience-session")) result.set("x-openscience-session", input.sessionID) + return result + } +} diff --git a/backend/cli/src/provider/provider.ts b/backend/cli/src/provider/provider.ts index 69eea1bee..72cbd3a56 100644 --- a/backend/cli/src/provider/provider.ts +++ b/backend/cli/src/provider/provider.ts @@ -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 @@ -105,6 +106,9 @@ export namespace Provider { abort?: AbortSignal /** Actual fetch dispatch, after local request and credential preparation. */ onRequest?: () => void + /** Written by the fetch watchdog after each response: the attempt's call id + * and, on the managed route, the Atlas hold that reserved it. */ + call?: { id: string; hold?: string } } export type RequestTiming = Pick & @@ -225,6 +229,11 @@ export namespace Provider { return requestContext.run(context, run) } + /** The latest attempt's call identity for the active request, if any. */ + export function currentCall() { + return requestContext.getStore()?.call + } + /** Keep the request context active for every lazy `next()` call. AI SDK * multi-step streams can start a later provider fetch only after a local * tool result, long after `LLM.stream()` itself returned. */ @@ -487,12 +496,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 = { 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, @@ -520,7 +530,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 @@ -537,6 +556,9 @@ export namespace Provider { signal, }) timing.responseStartedAt = Date.now() + const store = requestContext.getStore() + const hold = options.managed ? CallLink.hold(response.headers) : undefined + if (store) store.call = hold ? { id: call, hold } : { id: call } if (options.managed) Object.assign(timing, gatewayTiming(response.headers)) log.info("request response", { sessionID: timing.sessionID, diff --git a/backend/cli/test/provider/call-link.test.ts b/backend/cli/test/provider/call-link.test.ts new file mode 100644 index 000000000..0a27fca12 --- /dev/null +++ b/backend/cli/test/provider/call-link.test.ts @@ -0,0 +1,101 @@ +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 }) { + 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("ok", { 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() + return seen[0]! +} + +describe("call link", () => { + test("managed requests carry the call identity and capture a valid hold id", async () => { + const ctx = context() + const headers = await send({ + managed: true, + ctx, + response: new Headers({ "x-openscience-hold-id": "orgh_0123456789abcdef" }), + }) + expect(headers.get("x-openscience-call")).toMatch(/^[0-9a-f-]{36}$/) + expect(headers.get("x-openscience-message")).toBe("msg_link") + expect(headers.get("x-openscience-attempt")).toBe("2") + expect(headers.get("x-openscience-session")).toBe("ses_link") + expect(ctx.call).toEqual({ id: headers.get("x-openscience-call")!, hold: "orgh_0123456789abcdef" }) + }) + + test("a malformed hold id is ignored", async () => { + const ctx = context() + await send({ managed: true, ctx, response: new Headers({ "x-openscience-hold-id": "Bearer secret" }) }) + expect(ctx.call?.id).toBeDefined() + expect(ctx.call?.hold).toBeUndefined() + }) + + test("non-managed requests get a call id but send no link headers", async () => { + const ctx = context() + const headers = await send({ + managed: false, + ctx, + response: new Headers({ "x-openscience-hold-id": "hold_0123456789abcdef" }), + }) + expect(headers.get("x-openscience-call")).toBeNull() + expect(headers.get("x-openscience-message")).toBeNull() + expect(ctx.call?.id).toMatch(/^[0-9a-f-]{36}$/) + expect(ctx.call?.hold).toBeUndefined() + }) + + test("a Request input keeps its own headers", async () => { + const request = new Request("https://gateway.test", { + method: "POST", + headers: { authorization: "Bearer test-only", "Idempotency-Key": "logical" }, + body: "{}", + }) + const headers = await send({ managed: true, ctx: context(), request }) + expect(headers.get("authorization")).toBe("Bearer test-only") + expect(headers.get("Idempotency-Key")).toBe("logical") + expect(headers.get("x-openscience-call")).not.toBeNull() + }) + + test("without a request context nothing is recorded and nothing throws", async () => { + const headers = await send({ managed: true }) + expect(headers.get("x-openscience-call")).not.toBeNull() + expect(Provider.currentCall()).toBeUndefined() + }) + + test("each attempt gets a new call id and the context keeps the latest", async () => { + const ctx = context() + const first = await send({ managed: true, ctx }) + const second = await send({ managed: true, ctx }) + expect(first.get("x-openscience-call")).not.toBe(second.get("x-openscience-call")) + expect(ctx.call?.id).toBe(second.get("x-openscience-call")!) + }) + + test("the call id is the timing request id", async () => { + const timings: Provider.RequestTiming[] = [] + const off = Provider.onTiming((timing) => timings.push(timing)) + const ctx = context() + const headers = await send({ managed: true, ctx }) + off() + expect(timings.at(-1)?.requestID).toBe(headers.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() + }) +}) diff --git a/backend/cli/test/provider/idle-watchdog.test.ts b/backend/cli/test/provider/idle-watchdog.test.ts index 3b9df0fe3..2a2b523b7 100644 --- a/backend/cli/test/provider/idle-watchdog.test.ts +++ b/backend/cli/test/provider/idle-watchdog.test.ts @@ -79,7 +79,7 @@ function watched( init?: BunFetchRequestInit, ) { const timings: Provider.RequestTiming[] = [] - const response = Provider.withRequestContext(context, () => + const response = Provider.withRequestContext({ ...context }, () => Provider.fetchWithIdleWatchdog(fetchFn, "https://provider.test/v1/responses", init, { providerID: "test-provider", modelID: "test-model", From 2462bf11ce5a28ef7d6e6bae943423dad1ec871b Mon Sep 17 00:00:00 2001 From: KB Date: Mon, 5 Oct 2026 09:24:27 +0530 Subject: [PATCH 2/7] feat(session): record call and hold on step-finish and traces --- backend/cli/src/session/llm.ts | 1 + backend/cli/src/session/message-v2.ts | 4 +++ backend/cli/src/session/processor.ts | 6 +++- backend/cli/src/session/usage-logging.ts | 2 ++ .../cli/test/session/call-link-part.test.ts | 28 +++++++++++++++++++ .../cli/test/session/usage-logging.test.ts | 23 +++++++++++++++ tooling/sdk/js/src/v2/gen/types.gen.ts | 2 ++ tooling/sdk/openapi.json | 6 ++++ 8 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 backend/cli/test/session/call-link-part.test.ts diff --git a/backend/cli/src/session/llm.ts b/backend/cli/src/session/llm.ts index fbe832fa0..773da002a 100644 --- a/backend/cli/src/session/llm.ts +++ b/backend/cli/src/session/llm.ts @@ -331,6 +331,7 @@ export namespace LLM { duration: performance.now() - started, content: { parts: step.content, toolResults: step.toolResults }, finish: step.finishReason, + call: Provider.currentCall(), }).catch(() => l.warn("could not persist usage record")) }, async onError(error) { diff --git a/backend/cli/src/session/message-v2.ts b/backend/cli/src/session/message-v2.ts index c6186ea44..41ea03860 100644 --- a/backend/cli/src/session/message-v2.ts +++ b/backend/cli/src/session/message-v2.ts @@ -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. */ diff --git a/backend/cli/src/session/processor.ts b/backend/cli/src/session/processor.ts index c5dfd733d..768afd3b3 100644 --- a/backend/cli/src/session/processor.ts +++ b/backend/cli/src/session/processor.ts @@ -1022,7 +1022,7 @@ export namespace SessionProcessor { onTimeout: (error) => transport.abort(error), }) output.pause(toolOutcomes.active()) - const requestContext = { + const requestContext: Provider.RequestContext = { sessionID: input.sessionID, messageID: input.assistantMessage.id, attempt: attempt + 1, @@ -1262,6 +1262,10 @@ export namespace SessionProcessor { provider: resolved.model.providerID, model: resolved.model.id, time: Date.now(), + ...(requestContext.call && { + call: requestContext.call.id, + ...(requestContext.call.hold && { hold: requestContext.call.hold }), + }), }, tier: usage.tier, tokens: usage.tokens, diff --git a/backend/cli/src/session/usage-logging.ts b/backend/cli/src/session/usage-logging.ts index 5d0468f1b..d832b9e92 100644 --- a/backend/cli/src/session/usage-logging.ts +++ b/backend/cli/src/session/usage-logging.ts @@ -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 && { call_id: input.call.id, ...(input.call.hold && { hold: input.call.hold }) }), ...(input.content === undefined ? {} : { content: input.content }), ...(input.finish === undefined ? {} : { finish: input.finish }), }) diff --git a/backend/cli/test/session/call-link-part.test.ts b/backend/cli/test/session/call-link-part.test.ts new file mode 100644 index 000000000..1c8e0f8a6 --- /dev/null +++ b/backend/cli/test/session/call-link-part.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test" +import { MessageV2 } from "../../src/session/message-v2" + +const base = { + id: "prt_1", + sessionID: "ses_1", + messageID: "msg_1", + type: "step-finish" as const, + reason: "stop", + cost: 0, + tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }, +} + +test("step-finish keeps the call and hold", () => { + const part = MessageV2.StepFinishPart.parse({ + ...base, + usage: { route: "managed", provider: "openrouter", model: "m", time: 1, call: "c-1", hold: "orgh_abc" }, + }) + expect(part.usage).toMatchObject({ call: "c-1", hold: "orgh_abc" }) +}) + +test("older step-finish parts without the fields still parse", () => { + const part = MessageV2.StepFinishPart.parse({ + ...base, + usage: { route: "byok", provider: "openai", model: "m", time: 1 }, + }) + expect(part.usage?.call).toBeUndefined() +}) diff --git a/backend/cli/test/session/usage-logging.test.ts b/backend/cli/test/session/usage-logging.test.ts index 3c246c219..5df62a8cb 100644 --- a/backend/cli/test/session/usage-logging.test.ts +++ b/backend/cli/test/session/usage-logging.test.ts @@ -733,4 +733,27 @@ describe("usage delivery", () => { }, }) }) + test("a model response carries its call id and hold", async () => { + using remote = receiver() + await signedIn() + const context = await UsageLogging.context() + if (!context) throw new Error("fixture account is not signed in") + await UsageLogging.record({ + context, + sessionID: "ses_private-project-name", + messageID: "msg_private-conversation", + route: "managed", + provider: "openrouter", + model: "anthropic/claude-sonnet-4", + usage: measured, + duration: 10, + call: { id: "7f3c1e9a-0000-4000-8000-000000000001", hold: "orgh_0123456789abcdef" }, + }) + await UsageLogging.flush() + expect(remote.batches[0].events[0].payload).toMatchObject({ + call_id: "7f3c1e9a-0000-4000-8000-000000000001", + hold: "orgh_0123456789abcdef", + }) + expect(JSON.stringify(remote.batches)).not.toContain("private-conversation") + }) }) diff --git a/tooling/sdk/js/src/v2/gen/types.gen.ts b/tooling/sdk/js/src/v2/gen/types.gen.ts index d7d45ee73..fcb0d6007 100644 --- a/tooling/sdk/js/src/v2/gen/types.gen.ts +++ b/tooling/sdk/js/src/v2/gen/types.gen.ts @@ -564,6 +564,8 @@ export type StepFinishPart = { provider: string model: string time: number + call?: string + hold?: string } tier?: string reason: string diff --git a/tooling/sdk/openapi.json b/tooling/sdk/openapi.json index 39a3f40bd..1a65f9604 100644 --- a/tooling/sdk/openapi.json +++ b/tooling/sdk/openapi.json @@ -57952,6 +57952,12 @@ }, "time": { "type": "number" + }, + "call": { + "type": "string" + }, + "hold": { + "type": "string" } }, "required": [ From ded6922333de6e19e5724a0b66442afa832973d9 Mon Sep 17 00:00:00 2001 From: KB Date: Tue, 6 Oct 2026 06:09:39 +0530 Subject: [PATCH 3/7] fix(session): read a step's call before awaiting In multi-step tool streams the SDK can start the next step's fetch while finish-step awaits the snapshot, so the step could save the next call and hold. Capture the call first in both the processor and onStepFinish. --- backend/cli/src/session/llm.ts | 4 +++- backend/cli/src/session/processor.ts | 10 +++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/backend/cli/src/session/llm.ts b/backend/cli/src/session/llm.ts index 773da002a..6c051659d 100644 --- a/backend/cli/src/session/llm.ts +++ b/backend/cli/src/session/llm.ts @@ -323,6 +323,8 @@ export namespace LLM { await capture("assistant.message", { ...partial, interrupted: true }) }, async onStepFinish(step) { + // Capture before any await so the next step's fetch cannot replace it. + const call = Provider.currentCall() if (!binding) return await UsageLogging.record({ ...binding, @@ -331,7 +333,7 @@ export namespace LLM { duration: performance.now() - started, content: { parts: step.content, toolResults: step.toolResults }, finish: step.finishReason, - call: Provider.currentCall(), + call, }).catch(() => l.warn("could not persist usage record")) }, async onError(error) { diff --git a/backend/cli/src/session/processor.ts b/backend/cli/src/session/processor.ts index 768afd3b3..16e019b83 100644 --- a/backend/cli/src/session/processor.ts +++ b/backend/cli/src/session/processor.ts @@ -1230,6 +1230,10 @@ export namespace SessionProcessor { } case "finish-step": + // Read the call before any await: in a multi-step tool + // stream the SDK may start the next step's fetch while this + // step awaits, which replaces requestContext.call. + const stepCall = requestContext.call const funded = requiresWalletBalance(credentialSource) const usage = Session.getUsage({ model: resolved.model, @@ -1262,9 +1266,9 @@ export namespace SessionProcessor { provider: resolved.model.providerID, model: resolved.model.id, time: Date.now(), - ...(requestContext.call && { - call: requestContext.call.id, - ...(requestContext.call.hold && { hold: requestContext.call.hold }), + ...(stepCall && { + call: stepCall.id, + ...(stepCall.hold && { hold: stepCall.hold }), }), }, tier: usage.tier, From a1472ff3b04c5b7da7eae81c844fbaaf61b4b658 Mon Sep 17 00:00:00 2001 From: KB Date: Tue, 6 Oct 2026 20:20:23 +0530 Subject: [PATCH 4/7] fix(session): read a step's call and hold from its own response The call identity sat in a mutable slot on the shared request context, so in a multi-step stream the next step's fetch could overwrite it before the consumer handled the previous finish-step. The fetch wrapper now stamps the call id (and, on the managed route, the validated hold) onto the response it returns; the processor and onStepFinish read the step's own response headers. Non-managed responses never carry a hold. The trace key for the HTTP attempt becomes http_call_id so it no longer collides with the tool-call call_id on tool.* events. --- backend/cli/src/provider/call-link.ts | 35 ++++- backend/cli/src/provider/provider.ts | 30 ++--- backend/cli/src/session/llm.ts | 5 +- backend/cli/src/session/processor.ts | 11 +- backend/cli/src/session/usage-logging.ts | 2 +- backend/cli/test/provider/call-link.test.ts | 121 ++++++++++++------ .../cli/test/provider/idle-watchdog.test.ts | 2 +- .../cli/test/session/call-link-step.test.ts | 96 ++++++++++++++ .../cli/test/session/usage-logging.test.ts | 8 +- 9 files changed, 238 insertions(+), 72 deletions(-) create mode 100644 backend/cli/test/session/call-link-step.test.ts diff --git a/backend/cli/src/provider/call-link.ts b/backend/cli/src/provider/call-link.ts index c756a7fb1..87a07061f 100644 --- a/backend/cli/src/provider/call-link.ts +++ b/backend/cli/src/provider/call-link.ts @@ -1,22 +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 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}$/ - export function hold(headers: Headers) { - const value = headers.get("x-openscience-hold-id") + 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("x-openscience-call", input.call) + result.set(CALL_HEADER, input.call) result.set("x-openscience-message", input.messageID) result.set("x-openscience-attempt", String(input.attempt)) if (!result.has("x-openscience-session")) 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 | undefined) { + const id = headers?.[CALL_HEADER] + if (!id || !CALL.test(id)) return undefined + const hold = validHold(headers[HOLD_HEADER]) + return hold ? { id, hold } : { id } + } } diff --git a/backend/cli/src/provider/provider.ts b/backend/cli/src/provider/provider.ts index 72cbd3a56..5631376a8 100644 --- a/backend/cli/src/provider/provider.ts +++ b/backend/cli/src/provider/provider.ts @@ -106,9 +106,6 @@ export namespace Provider { abort?: AbortSignal /** Actual fetch dispatch, after local request and credential preparation. */ onRequest?: () => void - /** Written by the fetch watchdog after each response: the attempt's call id - * and, on the managed route, the Atlas hold that reserved it. */ - call?: { id: string; hold?: string } } export type RequestTiming = Pick & @@ -229,11 +226,6 @@ export namespace Provider { return requestContext.run(context, run) } - /** The latest attempt's call identity for the active request, if any. */ - export function currentCall() { - return requestContext.getStore()?.call - } - /** Keep the request context active for every lazy `next()` call. AI SDK * multi-step streams can start a later provider fetch only after a local * tool result, long after `LLM.stream()` itself returned. */ @@ -404,11 +396,11 @@ export namespace Provider { return "error" } - function copyResponse(response: Response, body: ReadableStream) { + function copyResponse(response: Response, body: ReadableStream | 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] }) @@ -556,9 +548,6 @@ export namespace Provider { signal, }) timing.responseStartedAt = Date.now() - const store = requestContext.getStore() - const hold = options.managed ? CallLink.hold(response.headers) : undefined - if (store) store.call = hold ? { id: call, hold } : { id: call } if (options.managed) Object.assign(timing, gatewayTiming(response.headers)) log.info("request response", { sessionID: timing.sessionID, @@ -577,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 @@ -646,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 diff --git a/backend/cli/src/session/llm.ts b/backend/cli/src/session/llm.ts index 6c051659d..ac0a5ed66 100644 --- a/backend/cli/src/session/llm.ts +++ b/backend/cli/src/session/llm.ts @@ -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" }) @@ -323,8 +324,6 @@ export namespace LLM { await capture("assistant.message", { ...partial, interrupted: true }) }, async onStepFinish(step) { - // Capture before any await so the next step's fetch cannot replace it. - const call = Provider.currentCall() if (!binding) return await UsageLogging.record({ ...binding, @@ -333,7 +332,7 @@ export namespace LLM { duration: performance.now() - started, content: { parts: step.content, toolResults: step.toolResults }, finish: step.finishReason, - call, + call: CallLink.fromResponse(step.response?.headers), }).catch(() => l.warn("could not persist usage record")) }, async onError(error) { diff --git a/backend/cli/src/session/processor.ts b/backend/cli/src/session/processor.ts index 16e019b83..b19eabc95 100644 --- a/backend/cli/src/session/processor.ts +++ b/backend/cli/src/session/processor.ts @@ -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" @@ -1022,7 +1023,7 @@ export namespace SessionProcessor { onTimeout: (error) => transport.abort(error), }) output.pause(toolOutcomes.active()) - const requestContext: Provider.RequestContext = { + const requestContext = { sessionID: input.sessionID, messageID: input.assistantMessage.id, attempt: attempt + 1, @@ -1230,10 +1231,10 @@ export namespace SessionProcessor { } case "finish-step": - // Read the call before any await: in a multi-step tool - // stream the SDK may start the next step's fetch while this - // step awaits, which replaces requestContext.call. - const stepCall = requestContext.call + // 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, diff --git a/backend/cli/src/session/usage-logging.ts b/backend/cli/src/session/usage-logging.ts index d832b9e92..78a4bfe92 100644 --- a/backend/cli/src/session/usage-logging.ts +++ b/backend/cli/src/session/usage-logging.ts @@ -250,7 +250,7 @@ export namespace UsageLogging { return event(input, "model.response", { ...reported(input.usage, input.metadata), duration_ms: input.duration, - ...(input.call && { call_id: input.call.id, ...(input.call.hold && { hold: input.call.hold }) }), + ...(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 }), }) diff --git a/backend/cli/test/provider/call-link.test.ts b/backend/cli/test/provider/call-link.test.ts index 0a27fca12..eec0fb7aa 100644 --- a/backend/cli/test/provider/call-link.test.ts +++ b/backend/cli/test/provider/call-link.test.ts @@ -4,11 +4,17 @@ 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 }) { +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("ok", { headers: input.response }) + 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 : {}, { @@ -18,42 +24,61 @@ async function send(input: { managed: boolean; response?: Headers; request?: Req }) const response = input.ctx ? await Provider.withRequestContext(input.ctx, run) : await run() await response.text() - return seen[0]! + // 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 capture a valid hold id", async () => { - const ctx = context() - const headers = await send({ + test("managed requests carry the call identity and the response carries the same call and a valid hold", async () => { + const { sent, link } = await send({ managed: true, - ctx, + ctx: context(), response: new Headers({ "x-openscience-hold-id": "orgh_0123456789abcdef" }), }) - expect(headers.get("x-openscience-call")).toMatch(/^[0-9a-f-]{36}$/) - expect(headers.get("x-openscience-message")).toBe("msg_link") - expect(headers.get("x-openscience-attempt")).toBe("2") - expect(headers.get("x-openscience-session")).toBe("ses_link") - expect(ctx.call).toEqual({ id: headers.get("x-openscience-call")!, hold: "orgh_0123456789abcdef" }) + expect(sent.get("x-openscience-call")).toMatch(/^[0-9a-f-]{36}$/) + expect(sent.get("x-openscience-message")).toBe("msg_link") + expect(sent.get("x-openscience-attempt")).toBe("2") + expect(sent.get("x-openscience-session")).toBe("ses_link") + expect(link).toEqual({ id: sent.get("x-openscience-call")!, hold: "orgh_0123456789abcdef" }) }) - test("a malformed hold id is ignored", async () => { - const ctx = context() - await send({ managed: true, ctx, response: new Headers({ "x-openscience-hold-id": "Bearer secret" }) }) - expect(ctx.call?.id).toBeDefined() - expect(ctx.call?.hold).toBeUndefined() + test("a malformed hold id is dropped from the response", async () => { + const { link, response } = await send({ + managed: true, + ctx: context(), + response: new Headers({ "x-openscience-hold-id": "Bearer secret" }), + }) + expect(link?.id).toBeDefined() + expect(link?.hold).toBeUndefined() + expect(response.headers.get("x-openscience-hold-id")).toBeNull() }) - test("non-managed requests get a call id but send no link headers", async () => { - const ctx = context() - const headers = await send({ + test("non-managed requests get a call id but send no link headers and keep no hold", async () => { + const { sent, link, response } = await send({ managed: false, - ctx, + ctx: context(), response: new Headers({ "x-openscience-hold-id": "hold_0123456789abcdef" }), }) - expect(headers.get("x-openscience-call")).toBeNull() - expect(headers.get("x-openscience-message")).toBeNull() - expect(ctx.call?.id).toMatch(/^[0-9a-f-]{36}$/) - expect(ctx.call?.hold).toBeUndefined() + expect(sent.get("x-openscience-call")).toBeNull() + expect(sent.get("x-openscience-message")).toBeNull() + expect(link?.id).toMatch(/^[0-9a-f-]{36}$/) + expect(link?.hold).toBeUndefined() + expect(response.headers.get("x-openscience-hold-id")).toBeNull() + }) + + test("an upstream cannot choose the call id", async () => { + const { sent, link } = await send({ + managed: true, + ctx: context(), + response: new Headers({ "x-openscience-call": "spoofed" }), + }) + expect(link?.id).toBe(sent.get("x-openscience-call")!) + }) + + test("a response without a body still carries its call", async () => { + const { sent, link } = await send({ managed: true, ctx: context(), body: null }) + expect(link?.id).toBe(sent.get("x-openscience-call")!) }) test("a Request input keeps its own headers", async () => { @@ -62,33 +87,35 @@ describe("call link", () => { headers: { authorization: "Bearer test-only", "Idempotency-Key": "logical" }, body: "{}", }) - const headers = await send({ managed: true, ctx: context(), request }) - expect(headers.get("authorization")).toBe("Bearer test-only") - expect(headers.get("Idempotency-Key")).toBe("logical") - expect(headers.get("x-openscience-call")).not.toBeNull() + const { sent } = await send({ managed: true, ctx: context(), request }) + expect(sent.get("authorization")).toBe("Bearer test-only") + expect(sent.get("Idempotency-Key")).toBe("logical") + expect(sent.get("x-openscience-call")).not.toBeNull() }) - test("without a request context nothing is recorded and nothing throws", async () => { - const headers = await send({ managed: true }) - expect(headers.get("x-openscience-call")).not.toBeNull() - expect(Provider.currentCall()).toBeUndefined() + test("without a request context the response still carries its call and nothing throws", async () => { + const { sent, link } = await send({ managed: true }) + expect(sent.get("x-openscience-call")).not.toBeNull() + expect(link?.id).toBe(sent.get("x-openscience-call")!) }) - test("each attempt gets a new call id and the context keeps the latest", async () => { + 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 }) - const second = await send({ managed: true, ctx }) - expect(first.get("x-openscience-call")).not.toBe(second.get("x-openscience-call")) - expect(ctx.call?.id).toBe(second.get("x-openscience-call")!) + 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 ctx = context() - const headers = await send({ managed: true, ctx }) + const { sent } = await send({ managed: true, ctx: context() }) off() - expect(timings.at(-1)?.requestID).toBe(headers.get("x-openscience-call")!) + expect(timings.at(-1)?.requestID).toBe(sent.get("x-openscience-call")!) }) test("hold() accepts only the two Atlas hold shapes", () => { @@ -98,4 +125,16 @@ describe("call link", () => { 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() + }) }) diff --git a/backend/cli/test/provider/idle-watchdog.test.ts b/backend/cli/test/provider/idle-watchdog.test.ts index 2a2b523b7..3b9df0fe3 100644 --- a/backend/cli/test/provider/idle-watchdog.test.ts +++ b/backend/cli/test/provider/idle-watchdog.test.ts @@ -79,7 +79,7 @@ function watched( init?: BunFetchRequestInit, ) { const timings: Provider.RequestTiming[] = [] - const response = Provider.withRequestContext({ ...context }, () => + const response = Provider.withRequestContext(context, () => Provider.fetchWithIdleWatchdog(fetchFn, "https://provider.test/v1/responses", init, { providerID: "test-provider", modelID: "test-model", diff --git a/backend/cli/test/session/call-link-step.test.ts b/backend/cli/test/session/call-link-step.test.ts new file mode 100644 index 000000000..aac6c8b4d --- /dev/null +++ b/backend/cli/test/session/call-link-step.test.ts @@ -0,0 +1,96 @@ +import { expect, test } from "bun:test" +import { createOpenRouter } from "@openrouter/ai-sdk-provider" +import { stepCountIs, streamText, tool } from "ai" +import z from "zod" +import { CallLink } from "../../src/provider/call-link" +import { Provider } from "../../src/provider/provider" + +function chunk(delta: Record, finish: string | null) { + return `data: ${JSON.stringify({ + id: "chatcmpl-link", + object: "chat.completion.chunk", + created: 1, + model: "openai/test", + choices: [{ index: 0, delta, finish_reason: finish }], + ...(finish ? { usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 } } : {}), + })}\n\n` +} + +const toolStep = () => + chunk( + { + role: "assistant", + tool_calls: [{ index: 0, id: "call_tool_1", type: "function", function: { name: "lookup", arguments: "{}" } }], + }, + null, + ) + + chunk({}, "tool_calls") + + "data: [DONE]\n\n" +const textStep = () => chunk({ role: "assistant", content: "done" }, null) + chunk({}, "stop") + "data: [DONE]\n\n" + +async function until(check: () => boolean, timeoutMs: number) { + const deadline = Date.now() + timeoutMs + while (!check() && Date.now() < deadline) await Bun.sleep(5) +} + +// The processor and onStepFinish read a step's identity from that step's own +// response headers. This drives the real AI SDK with the managed fetch wrapper +// and holds back the consumer until the next step's fetch has been sent, the +// order in which shared request state would have named the wrong call. +test("each step reads the call and hold of its own response, even when the next fetch has started", async () => { + const sent: { call: string; hold: string }[] = [] + using server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + const hold = `hold_step${sent.length + 1}` + sent.push({ call: request.headers.get("x-openscience-call")!, hold }) + return new Response(sent.length === 1 ? toolStep() : textStep(), { + headers: { "content-type": "text/event-stream", "x-openscience-hold-id": hold }, + }) + }, + }) + const provider = createOpenRouter({ + apiKey: "test-local-only", + baseURL: `${server.url.origin}/api/v1`, + fetch: ((input: any, init?: RequestInit) => + Provider.fetchWithIdleWatchdog(fetch, input, init, { + providerID: "openrouter", + modelID: "openai/test", + managed: true, + })) as typeof fetch, + }) + const onStep: ({ id: string; hold?: string } | undefined)[] = [] + const context: Provider.RequestContext = { sessionID: "ses_step", messageID: "msg_step", attempt: 1 } + const finished = await Provider.withRequestContext(context, async () => { + const result = streamText({ + model: provider.chat("openai/test"), + prompt: "Use the tool, then answer.", + tools: { lookup: tool({ description: "fixture", inputSchema: z.object({}), execute: async () => "ok" }) }, + stopWhen: stepCountIs(2), + maxRetries: 0, + abortSignal: AbortSignal.timeout(5_000), + onStepFinish(step) { + onStep.push(CallLink.fromResponse(step.response?.headers)) + }, + }) + const steps: ({ id: string; hold?: string } | undefined)[] = [] + for await (const part of Provider.withRequestContextIterable(context, result.fullStream)) { + if (part.type !== "finish-step") continue + // Lag like a busy consumer: let the SDK send the next step first. + if (steps.length === 0) { + await until(() => sent.length === 2, 2_000) + // The next step was already sent while this step was unread. + expect(sent).toHaveLength(2) + } + steps.push(CallLink.fromResponse(part.response?.headers)) + } + return steps + }) + + expect(sent).toHaveLength(2) + expect(sent[0]!.call).not.toBe(sent[1]!.call) + const expected = sent.map((item) => ({ id: item.call, hold: item.hold })) + expect(finished).toEqual(expected) + expect(onStep).toEqual(expected) +}) diff --git a/backend/cli/test/session/usage-logging.test.ts b/backend/cli/test/session/usage-logging.test.ts index 5df62a8cb..12d978e97 100644 --- a/backend/cli/test/session/usage-logging.test.ts +++ b/backend/cli/test/session/usage-logging.test.ts @@ -750,10 +750,14 @@ describe("usage delivery", () => { call: { id: "7f3c1e9a-0000-4000-8000-000000000001", hold: "orgh_0123456789abcdef" }, }) await UsageLogging.flush() - expect(remote.batches[0].events[0].payload).toMatchObject({ - call_id: "7f3c1e9a-0000-4000-8000-000000000001", + const payload = remote.batches[0].events[0].payload + // tool.* events use call_id for the model's tool-call id; the HTTP + // attempt gets its own key so the two never collide in a trace. + expect(payload).toMatchObject({ + http_call_id: "7f3c1e9a-0000-4000-8000-000000000001", hold: "orgh_0123456789abcdef", }) + expect(payload).not.toHaveProperty("call_id") expect(JSON.stringify(remote.batches)).not.toContain("private-conversation") }) }) From 0c878e409b17838904be9fbd800d5e24842c430f Mon Sep 17 00:00:00 2001 From: KB Date: Tue, 6 Oct 2026 22:39:25 +0530 Subject: [PATCH 5/7] fix(usage): skip session and message records without an id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local usage listed storage under a record's id without checking it, so a legacy or corrupt session or message file with no id made Settings → Usage fail with a 500. Such records have nothing to list; skip them. --- backend/cli/src/session/usage.ts | 5 ++-- .../cli/test/server/settings-usage.test.ts | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/backend/cli/src/session/usage.ts b/backend/cli/src/session/usage.ts index 8ee88f475..80bbaebae 100644 --- a/backend/cli/src/session/usage.ts +++ b/backend/cli/src/session/usage.ts @@ -12,7 +12,8 @@ export async function localUsage(start: number, end: number): Promise= end) continue diff --git a/backend/cli/test/server/settings-usage.test.ts b/backend/cli/test/server/settings-usage.test.ts index 0117aae8e..5994296cd 100644 --- a/backend/cli/test/server/settings-usage.test.ts +++ b/backend/cli/test/server/settings-usage.test.ts @@ -46,6 +46,35 @@ test("usage records each completed step once, uses its actual route, and exclude expect(row).toMatchObject({ route: "byok", calls: 2, tokens: 350, output: 40, reasoning: 20, cost: 0.04 }) }) +test("local usage skips session and message records without an id", async () => { + await write(["session", "usage-test", "no-id"], { time: { created: start, updated: start + 2000 } }) + await write(["session", "usage-test", "kept"], { id: "kept", time: { created: start, updated: start + 2000 } }) + await write(["message", "kept", "no-id"], { + sessionID: "kept", + role: "assistant", + providerID: "openrouter", + modelID: "orphan", + time: { created: start + 1 }, + cost: 1, + tokens, + }) + await write(["message", "kept", "kept"], { + id: "kept", + sessionID: "kept", + role: "assistant", + providerID: "openrouter", + modelID: "kept-model", + time: { created: start + 1 }, + cost: 1, + tokens, + }) + const response = await UsageSettingsRoutes().request("/local?start=2026-09-01&end=2026-09-01") + expect(response.status).toBe(200) + const result = (await response.json()) as { rows: UsageRow[] } + expect(result.rows.map((row) => row.model)).toContain("kept-model") + expect(result.rows.map((row) => row.model)).not.toContain("orphan") +}) + test("local usage honors day boundaries and keeps ambiguous historical routes separate", async () => { await write(["session", "usage-test", "dates"], { id: "dates", From 1345d7039b22e49031953af1e350a059048ffb5f Mon Sep 17 00:00:00 2001 From: KB Date: Tue, 6 Oct 2026 23:24:39 +0530 Subject: [PATCH 6/7] =?UTF-8?q?docs(changelog):=20note=20the=20Settings=20?= =?UTF-8?q?=E2=86=92=20Usage=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6371bfb6..8beac54b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ tagged release also ships native binaries for Linux, macOS, and Windows. ## Unreleased +- Settings → Usage no longer fails when a saved session or message record has no id; such records are skipped. + - Dataset helpers create private repositories by default with an explicit `--public` option, propagate failed uploads, validate QA/completion and union fields, and substitute SQL table placeholders without modifying quoted text or comments. - Restore BRENDA fallback enzyme queries, fix filtered and extended market-visual dry runs, and report zero TMB with accurate counts when every variant is filtered out. From f477720ac7ce55413f6b14036b6b011a3e313771 Mon Sep 17 00:00:00 2001 From: KB Date: Sat, 10 Oct 2026 13:05:23 +0530 Subject: [PATCH 7/7] fix(provider): harden call links across managed retries --- CHANGELOG.md | 2 + backend/cli/src/provider/call-link.ts | 2 +- backend/cli/test/provider/call-link.test.ts | 77 +++++++++++-------- .../test/provider/managed-conflict.test.ts | 43 +++++++++-- .../cli/test/session/call-link-step.test.ts | 2 +- 5 files changed, 85 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 418a2bf41..b849e0623 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ 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. diff --git a/backend/cli/src/provider/call-link.ts b/backend/cli/src/provider/call-link.ts index 87a07061f..a387fe27d 100644 --- a/backend/cli/src/provider/call-link.ts +++ b/backend/cli/src/provider/call-link.ts @@ -24,7 +24,7 @@ export namespace CallLink { result.set(CALL_HEADER, input.call) result.set("x-openscience-message", input.messageID) result.set("x-openscience-attempt", String(input.attempt)) - if (!result.has("x-openscience-session")) result.set("x-openscience-session", input.sessionID) + result.set("x-openscience-session", input.sessionID) return result } diff --git a/backend/cli/test/provider/call-link.test.ts b/backend/cli/test/provider/call-link.test.ts index eec0fb7aa..96000779e 100644 --- a/backend/cli/test/provider/call-link.test.ts +++ b/backend/cli/test/provider/call-link.test.ts @@ -31,72 +31,87 @@ async function send(input: { describe("call link", () => { test("managed requests carry the call identity and the response carries the same call and a valid hold", async () => { - const { sent, link } = await send({ + const result = await send({ managed: true, ctx: context(), response: new Headers({ "x-openscience-hold-id": "orgh_0123456789abcdef" }), }) - expect(sent.get("x-openscience-call")).toMatch(/^[0-9a-f-]{36}$/) - expect(sent.get("x-openscience-message")).toBe("msg_link") - expect(sent.get("x-openscience-attempt")).toBe("2") - expect(sent.get("x-openscience-session")).toBe("ses_link") - expect(link).toEqual({ id: sent.get("x-openscience-call")!, hold: "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 { link, response } = await send({ + const result = await send({ managed: true, ctx: context(), response: new Headers({ "x-openscience-hold-id": "Bearer secret" }), }) - expect(link?.id).toBeDefined() - expect(link?.hold).toBeUndefined() - expect(response.headers.get("x-openscience-hold-id")).toBeNull() + 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 { sent, link, response } = await send({ + const result = await send({ managed: false, ctx: context(), response: new Headers({ "x-openscience-hold-id": "hold_0123456789abcdef" }), }) - expect(sent.get("x-openscience-call")).toBeNull() - expect(sent.get("x-openscience-message")).toBeNull() - expect(link?.id).toMatch(/^[0-9a-f-]{36}$/) - expect(link?.hold).toBeUndefined() - expect(response.headers.get("x-openscience-hold-id")).toBeNull() + 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 { sent, link } = await send({ + const result = await send({ managed: true, ctx: context(), response: new Headers({ "x-openscience-call": "spoofed" }), }) - expect(link?.id).toBe(sent.get("x-openscience-call")!) + expect(result.link?.id).toBe(result.sent.get("x-openscience-call")!) }) test("a response without a body still carries its call", async () => { - const { sent, link } = await send({ managed: true, ctx: context(), body: null }) - expect(link?.id).toBe(sent.get("x-openscience-call")!) + 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 its own headers", async () => { + 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" }, + 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 { sent } = await send({ managed: true, ctx: context(), request }) - expect(sent.get("authorization")).toBe("Bearer test-only") - expect(sent.get("Idempotency-Key")).toBe("logical") - expect(sent.get("x-openscience-call")).not.toBeNull() + 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 { sent, link } = await send({ managed: true }) - expect(sent.get("x-openscience-call")).not.toBeNull() - expect(link?.id).toBe(sent.get("x-openscience-call")!) + 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 () => { @@ -113,9 +128,9 @@ describe("call link", () => { test("the call id is the timing request id", async () => { const timings: Provider.RequestTiming[] = [] const off = Provider.onTiming((timing) => timings.push(timing)) - const { sent } = await send({ managed: true, ctx: context() }) + const result = await send({ managed: true, ctx: context() }) off() - expect(timings.at(-1)?.requestID).toBe(sent.get("x-openscience-call")!) + expect(timings.at(-1)?.requestID).toBe(result.sent.get("x-openscience-call")!) }) test("hold() accepts only the two Atlas hold shapes", () => { diff --git a/backend/cli/test/provider/managed-conflict.test.ts b/backend/cli/test/provider/managed-conflict.test.ts index 0a84602ff..55c6f25d7 100644 --- a/backend/cli/test/provider/managed-conflict.test.ts +++ b/backend/cli/test/provider/managed-conflict.test.ts @@ -5,6 +5,7 @@ import { GlobalBus } from "../../src/bus/global" import { OpenScience } from "../../src/openscience" import { Instance } from "../../src/project/instance" import { MANAGED_MODEL_DETAILS, MANAGED_OPENROUTER_MODELS } from "../../src/provider/managed-catalog" +import { CallLink } from "../../src/provider/call-link" import { Provider } from "../../src/provider/provider" import { MessageV2 } from "../../src/session/message-v2" import { SessionProcessor } from "../../src/session/processor" @@ -50,7 +51,13 @@ const replay = () => choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 }, }, - { headers: { ...funding, "x-openscience-idempotent-replay": "true" } }, + { + headers: { + ...funding, + "x-openscience-idempotent-replay": "true", + "x-openscience-hold-id": "hold_replay", + }, + }, ) const conflict = () => Response.json( @@ -91,7 +98,7 @@ const unknown = (status: 409 | 410) => const upstream = () => Response.json({ error: { message: "upstream conflict", code: "temporary_conflict" } }, { status: 409 }) -type Seen = { key: string | null; body: string } +type Seen = { key: string | null; call: string | null; body: string } /** A real gateway whose chat completions follow a scripted response sequence. */ function gateway(script: Array<() => Response>) { @@ -102,7 +109,11 @@ function gateway(script: Array<() => Response>) { const url = new URL(request.url) if (url.pathname.endsWith("/model-catalog")) return Response.json({ models: catalog }, { headers: funding }) if (!url.pathname.endsWith("/chat/completions")) return Response.json({}, { headers: funding }) - seen.push({ key: request.headers.get("Idempotency-Key"), body: await request.text() }) + seen.push({ + key: request.headers.get("Idempotency-Key"), + call: request.headers.get("x-openscience-call"), + body: await request.text(), + }) const next = script.shift() if (!next) return Response.json({ error: "fixture script exhausted" }, { status: 500 }) return next() @@ -139,14 +150,21 @@ function settle( signal: input.signal, } const url = new URL("/api/llm/proxy/openrouter/v1/chat/completions", server.url).href - const request = () => fetch(url, init) + const managed = input.managed ?? true + const request = () => + Provider.fetchWithIdleWatchdog(fetch, url, init, { + providerID: "openrouter", + modelID: "openai/gpt-6-luna", + idleTimeout: false, + managed, + }) const timings: Provider.RequestTiming[] = [] const started = Date.now() const response = Provider.withRequestContext(input.context ?? context, () => request().then((first) => Provider.retryManagedConflict({ response: first, - managed: input.managed ?? true, + managed, headers, signal: input.signal, retry: request, @@ -180,8 +198,15 @@ describe("managed conflict guard", () => { ) expect(elapsed()).toBeGreaterThanOrEqual(1000) expect(fixture.seen).toHaveLength(2) - expect(fixture.seen[1]).toEqual(fixture.seen[0]) + expect(fixture.seen[1]).toMatchObject({ key: fixture.seen[0].key, body: fixture.seen[0].body }) expect(fixture.seen[0].key).toBe("os_fixture") + expect(fixture.seen[0].call).toMatch(/^[0-9a-f-]{36}$/) + expect(fixture.seen[1].call).toMatch(/^[0-9a-f-]{36}$/) + expect(fixture.seen[1].call).not.toBe(fixture.seen[0].call) + expect(CallLink.fromResponse(Object.fromEntries(result.headers))).toEqual({ + id: fixture.seen[1].call!, + hold: "hold_replay", + }) expect(timings).toHaveLength(1) expect(timings[0]).toMatchObject({ ...context, @@ -374,7 +399,8 @@ describe("managed conflict guard", () => { expect(result.text).toBe("ok") expect(fixture.seen).toHaveLength(2) expect(fixture.seen[0].key).toStartWith("os_") - expect(fixture.seen[1]).toEqual(fixture.seen[0]) + expect(fixture.seen[1]).toMatchObject({ key: fixture.seen[0].key, body: fixture.seen[0].body }) + expect(fixture.seen[1].call).not.toBe(fixture.seen[0].call) const error = await failure( Provider.withRequestContext({ ...scope, messageID: "msg_sealed" }, () => @@ -408,7 +434,8 @@ describe("managed conflict guard", () => { expect(fixture.seen[3].key).toStartWith("os_") await dispatch(2) expect(fixture.seen).toHaveLength(5) - expect(fixture.seen[4]).toEqual(fixture.seen[3]) + expect(fixture.seen[4]).toMatchObject({ key: fixture.seen[3].key, body: fixture.seen[3].body }) + expect(fixture.seen[4].call).not.toBe(fixture.seen[3].call) }, }) } finally { diff --git a/backend/cli/test/session/call-link-step.test.ts b/backend/cli/test/session/call-link-step.test.ts index aac6c8b4d..cd94d3fb0 100644 --- a/backend/cli/test/session/call-link-step.test.ts +++ b/backend/cli/test/session/call-link-step.test.ts @@ -53,7 +53,7 @@ test("each step reads the call and hold of its own response, even when the next const provider = createOpenRouter({ apiKey: "test-local-only", baseURL: `${server.url.origin}/api/v1`, - fetch: ((input: any, init?: RequestInit) => + fetch: ((input: Parameters[0], init?: RequestInit) => Provider.fetchWithIdleWatchdog(fetch, input, init, { providerID: "openrouter", modelID: "openai/test",