Skip to content

fix(core): run session HTTP hooks on the AI SDK route - #50487

Merged
rekram1-node merged 1 commit into
v2from
aisdk-http-hooks
Sep 22, 2026
Merged

rekram1-node merged 1 commit into
v2from
aisdk-http-hooks

Conversation

@rekram1-node

@rekram1-node rekram1-node commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

session.http.request / session.http.response never fired for models served through AI SDK packages. packages/ai already hands the middleware to every route; the AI SDK route in core dropped it on the floor.

 LLMClient.stream(request, { http })
   compile(request)
   route.streamPrepared(prepared, request, runtime, options)
     native route (Anthropic, OpenAIChat, …)
       HttpTransport.execute
         RequestExecutor.execute(request, options.http)   ← hooks fire
     ai-sdk route (packages/core/src/aisdk.ts)
-      streamLanguage(language, prepared)                  ← options ignored
+      streamLanguage(language, prepared, options.http)
         language.doStream(callOptions)
           options.fetch(input, init)                       ← SDK-owned fetch
-            send(input, init)
+            middleware ? throughMiddleware(...) : send(input, init)

Fallout today: the Copilot http.request hook only saw Claude; the Azure (Entra bearer) and Snowflake (conversation complete 400 → synthetic SSE stop) hooks were dead code.

Why not just pass the middleware to fetch?

The SDK's fetch is fixed at construction and the language model is cached per catalog model. The middleware is per request. So the wrapper cannot close over it; it has to be looked up per call.

AISDK.language(model)            cached: one LanguageModelV3 per catalog model
  prepareOptions(model)          installs one fetch wrapper, once
    options.fetch = (input, init) => …

SessionModelRequest.primary()    per step
  httpMiddleware(hooks, scope)   closes over sessionID, agent, kind
  llm.stream(request, { http })  ← must reach the wrapper above

The middleware rides through AsyncLocalStorage for the duration of doStream:

streamLanguage(language, callOptions, http)
  context = Effect.context()
  httpMiddleware.run({ http, context }, () => language.doStream(callOptions))
    …SDK internals, retries…
      options.fetch(input, init)
        store = httpMiddleware.getStore()      ← present only inside this call
        throughMiddleware(store, send, input, init)

One request through the wrapper

sequenceDiagram
    participant SDK as AI SDK model
    participant W as fetch wrapper (aisdk.ts)
    participant M as http middleware (model-request.ts)
    participant H as plugin hooks
    participant Net as upstream fetch

    SDK->>W: fetch(url, init{string body})
    W->>W: Request → HttpClientRequest, body → Uint8Array
    W->>M: http(request, handler)
    M->>H: session.http.request
    H-->>M: mutated Request
    M->>W: handler(sent)
    W->>W: HttpClientRequest → (url, init)
    W->>Net: send(url, init)
    Net-->>W: Response
    W-->>M: HttpClientResponse.fromWeb
    M->>H: session.http.response
    H-->>M: mutated Response
    M-->>W: HttpClientResponse
    W->>W: stream + status + headers → Response
    W-->>SDK: Response
Loading

Notes on the edges:

  • Body is materialized to bytes before hooks see it, so a hook may call HttpClientRequest.toWeb and clone().text() freely (the real middleware in model-request.ts does exactly that). Matches the native route, where bodies are never a single-use stream.
  • The Effect runs with Effect.runPromiseWith(callerContext), so spans stay attached, and it takes the SDK's abort signal.
  • The SDK's own retry loop calls fetch again per attempt; each attempt goes through the middleware, same as the native executor.
  • No middleware ⇒ the old direct send(input, init) path, unchanged.

Also

  • Removed the comment in the Copilot plugin claiming the AI SDK route bypasses http.request.
  • No changes in packages/ai.

Testing

New in packages/core/test/aisdk.test.ts, both against a real @ai-sdk/openai-compatible model via LLMClient.generate:

  • with middleware: hook sees POST …/chat/completions, reads the body twice, sets x-hook; upstream fetch receives x-hook alongside the SDK's Authorization: Bearer test; a rewritten SSE response is what the stream yields.
  • without middleware: the SDK's string body reaches fetch untouched.

bun test across aisdk, aisdk-native, session-model-request-hooks, session-runner, and the Copilot/Azure/OpenAI plugin tests: 265 pass. bun run check clean.

Live verification

Standalone dev server from this branch, a throwaway .opencode/plugins plugin registering http.request/http.response for github-copilot, real requests to CAPI:

kind model route endpoint hook saw reply
primary gpt-5.4-mini AI SDK /responses headers, 30 KB body, 200 SSE ✅
title gpt-5.6-luna AI SDK /responses conversation-background, 200 ✅ title generated
primary claude-haiku-4.5 native /v1/messages unchanged ✅

Bold rows were unreachable by these hooks before this change.

@rekram1-node
rekram1-node merged commit 60673aa into v2 Sep 22, 2026
14 checks passed
@rekram1-node
rekram1-node deleted the aisdk-http-hooks branch September 22, 2026 03:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant