Skip to content

Migrate ai-groq, ai-openrouter, ai-ollama to openai-base + parameterize the base for SDK shape variance #543

@tombeckenham

Description

@tombeckenham

Captured from PR #409 comment so the design proposal doesn't get lost regardless of how #409 lands.

Context

After #409 merges, only ai-openai and ai-grok will extend the new @tanstack/openai-base. ai-groq, ai-openrouter, and ai-ollama still extend BaseTextAdapter directly — together that's ~2k lines of duplicated text-adapter code the bases were designed to absorb.

For ai-groq the migration looks mechanical (Chat Completions wire format, OpenAI SDK works fine pointed at Groq's endpoint). ai-openrouter is the interesting one — design question worth resolving before assuming it slots in.

Wire format: identical. SDK shape: not.

OpenRouter's HTTP API is OpenAI-compatible Chat Completions, so on the wire the base's SSE accumulator, partial-JSON, tool-call buffers, and RUN_ERROR taxonomy all apply unchanged. But the TypeScript SDK (@openrouter/sdk, Speakeasy-generated) has a different shape from the OpenAI SDK the base assumes:

OpenAI SDK (base) @openrouter/sdk
Call shape client.chat.completions.create(params) client.chat.send({ chatRequest: params })
Field naming snake_case (max_completion_tokens, top_p) camelCase (maxCompletionTokens, topP)
Abort error APIUserAbortError RequestAbortedError from @openrouter/sdk/models/errors

Grok gets away with a ~60-line subclass because xAI's "OpenAI-compatible" endpoint accepts the OpenAI SDK verbatim — withGrokDefaults just rewrites the baseURL. OpenRouter's SDK is not a drop-in.

OpenRouter-specific surface

These are real user-facing features, not incidental:

  1. Provider routingprovider: { order, fallbacks, allow_fallbacks, sort, ignore }, models[] fallback chain, route, transforms: ['middle-out']. OpenRouter's distinguishing feature.
  2. Response metadata — which provider served the request, which underlying model was actually used, generation ID for cost lookup.
  3. App attribution headersHTTP-Referer, X-Title.

Considered and ruled out: dropping @openrouter/sdk

The cleanest-looking option would be replacing @openrouter/sdk with the OpenAI SDK pointed at openrouter.ai/api/v1 (the grok approach). That deletes ~700 lines and the base works unchanged.

Ruled out: it forces provider routing through untyped extra_body pass-through and loses the typed response metadata. That's a type-safety regression on OpenRouter's headline feature, and the SDK is purpose-built around exactly that surface — keeping it is the point.

Proposal: parameterize the base with two protected hooks

Add to OpenAICompatibleChatCompletionsTextAdapter:

protected callChatCompletion(params, signal): Promise<ChatCompletion>
protected callChatCompletionStream(params, signal): AsyncIterable<ChatCompletionChunk>

Default impl calls the OpenAI SDK (covers grok and any future "OpenAI-compatible-endpoint" providers like deepseek / together / fireworks). ai-openrouter overrides to call @openrouter/sdk.

Everything else — tool/message converters, SSE accumulator, partial-JSON, RUN_ERROR taxonomy, structured output, lifecycle — stays in the base. The ai-openrouter subclass collapses to ~150 lines (SDK shim + provider-routing typing + response metadata extraction) vs. 807 today.

Why this matters for #527

PR #527 (streaming structured output) currently duplicates ~290 lines of structuredOutput + chatStreamStructured across openai/grok/groq. The plan there is to lift that into the bases so each wire format has one implementation. That lift only covers openrouter if openrouter is on the Chat Completions base. If #409 ships with openrouter unmigrated, #527 has to either keep duplicating into openrouter or fold the openrouter migration into a feature PR.

Open questions

  1. Was leaving ai-groq / ai-openrouter / ai-ollama on BaseTextAdapter deliberate (scoping feat: extract @tanstack/openai-base and @tanstack/ai-utils packages #409 to "introduce bases, migrate later") or time-boxed?
  2. Expand scope (here or in a follow-up PR) to migrate ai-groq + ai-openrouter?
  3. Any concerns with the two-hook approach, or alternative shapes preferred?

Happy to prototype the hook signatures on a side branch if it'd help the conversation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions