Provider Streaming Patterns

SkillAI & models

LLM provider streaming patterns and per-provider quirks. Use when adding a new provider, implementing adapter hooks, or debugging streaming tool call behavior in packages/llm-provider.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Provider Streaming Patterns skill

What this skill tells your AI

The instructions your AI receives, as published by tylerjrbuell/reactive-agents-ts in .agents/skills/provider-streaming/SKILL.md and read by ahel’s review.

The One Rule That Applies to ALL Providers

Pass tools to BOTH complete() AND stream().

Failing to pass tools to stream() causes silent tool call failures where the LLM receives no tool schemas and falls back to text output.

// CORRECT — tools on both:
const response = yield* llm.complete({ messages, tools: toolSchemas, maxTokens: 4096 });
const stream = yield* llm.stream({ messages, tools: toolSchemas, maxTokens: 4096 });

// WRONG — tools only on complete:
const stream = yield* llm.stream({ messages, maxTokens: 4096 }); // missing tools

Per-Provider Streaming Quirks

These bugs have been introduced multiple times. Treat them as rules.

Anthropic

Rule: Use raw streamEvent, not the SDK helper events.

// WRONG — inputJson fires before contentBlock, causing missed content:
stream.on("inputJson", (delta) => { ... });

// CORRECT — use streamEvent for ordering guarantees:
stream.on("streamEvent", (event) => {
  if (event.type === "content_block_delta") { ... }
  if (event.type === "tool_use") { ... }
});

Gemini

Rule: functionResponse.name must use msg.toolName, not a hard-coded string.

// WRONG — breaks multi-tool scenarios:
{
  functionResponse: {
    name: "tool",  // hard-coded — wrong
    response: toolResult,
  }
}

// CORRECT:
{
  functionResponse: {
    name: msg.toolName,  // use the actual tool name from the message
    response: toolResult,
  }
}

Ollama

Rule: Tool calls are on chunk.done, not streamed incrementally. Emit synthetic events.

// CORRECT Ollama streaming pattern:
if (chunk.done && chunk.message.tool_calls) {
  for (const tc of chunk.message.tool_calls) {
    yield { type: "tool_use_start", toolName: tc.function.name };
    yield { type: "tool_use_delta", delta: JSON.stringify(tc.function.arguments) };
  }
}

Do NOT attempt to stream Ollama tool call arguments incrementally — they arrive only on the final done chunk.

OpenAI / LiteLLM / Others

Standard streaming patterns apply. Follow the StreamEvent type definitions in packages/llm-provider/src/types.ts.

Adding a New Provider

Required: 7 methods on LLMService

// packages/llm-provider/src/providers/<name>.ts
export const create<Name>Provider = (config: ProviderConfig): LLMService["_tag"] => ({
  complete: (request) => Effect.gen(function* () { ... }),
  stream: (request) => Effect.gen(function* () { ... }),        // ← must accept tools
  completeStructured: (request) => Effect.gen(function* () { ... }),
  embed: (texts, model?) => Effect.gen(function* () { ... }),
  countTokens: (messages) => Effect.gen(function* () { ... }),
  getModelConfig: () => Effect.succeed({ provider: "<name>", model: config.model }),
});

Required: Declare ProviderCapabilities

// In packages/llm-provider/src/capabilities.ts or provider file:
export const myProviderCapabilities: ProviderCapabilities = {
  supportsNativeFunctionCalling: true,  // or false
  supportsStreaming: true,
  supportsPromptCaching: false,
  supportedTiers: ["t1", "t2", "t3"],
};

Required: Register in createLLMProviderLayer

// packages/llm-provider/src/runtime.ts
case "my-provider":
  return createMyProviderLayer(config);

Provider Adapter Hooks

4-hook adapter system: hooks that allow strategies to inject provider-specific guidance, plus parseToolCalls for provider-specific tool-call extraction. Wire them via selectAdapter(capabilities, tier). (taskFraming, toolGuidance, and systemPromptPatch were removed in v0.14.)

HookWhen it firesPurpose
continuationHintAfter tool resultsNudge toward next action
errorRecoveryOn tool/LLM errorRecovery message phrasing
synthesisPromptPre-final-answerSynthesis framing
qualityCheckPost-outputOutput quality validation prompt
parseToolCallsOn raw model outputProvider-specific tool-call extraction
// Usage in a phase:
const adapter = selectAdapter(context.capabilities, context.tier);
const hint = adapter.continuationHint?.({ toolsUsed, requiredTools, missingTools, iteration, maxIterations }) ?? "";

Testing Provider Streaming

// tests/providers/<name>.test.ts
// Run: bun test packages/llm-provider/tests/providers/<name>.test.ts --timeout 15000
import { Effect } from "effect";
import { describe, it, expect } from "bun:test";

it("should stream tool calls with correct event sequence", async () => {
  const events: StreamEvent[] = [];

  await Effect.gen(function* () {
    const llm = yield* LLMService;
    const stream = yield* llm.stream({
      messages: [{ role: "user", content: "use the test tool" }],
      tools: [testToolDefinition],
    });
    yield* Stream.runForEach(stream, (event) =>
      Effect.sync(() => events.push(event)),
    );
  }).pipe(Effect.provide(myProviderLayer), Effect.runPromise);

  // Verify event ordering
  const toolUseStart = events.find(e => e.type === "tool_use_start");
  const toolUseDelta = events.find(e => e.type === "tool_use_delta");
  expect(toolUseStart).toBeDefined();
  expect(toolUseDelta).toBeDefined();
  expect(events.at(-1)?.type).toBe("message_stop");
}, 15000);

Signals

GitHub stars
27
Forks
4
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
provider-streaming
Source
github.com/tylerjrbuell/reactive-agents-ts