cd /news/ai-tools/190proof-opinionated-unified-interfa… · home topics ai-tools article
[ARTICLE · art-126915] src=github.com ↗ pub= topic=ai-tools verified=true sentiment=· neutral

190proof opinionated unified interface to interact with multiple AI providers

A new npm package called 190proof provides a unified interface for calling models from OpenAI, Anthropic, Google, Groq, OpenRouter, and AWS Bedrock through a single `provider:model-id` string format. The package handles retries, streaming by default, function/tool calling, message alternation, and image normalization across providers, and it degrades images to inline text references for text-only providers such as Groq or when OpenRouter rejects a request with a routing-layer 404. It is installed via `npm install 190proof` and exposes a `callWithRetries` function alongside a `GenericPayload` type.

read8 min views4 publishedSep 11, 2026
190proof opinionated unified interface to interact with multiple AI providers
Image: Michielbdejong (auto-discovered)

An opinionated unified interface for interacting with multiple AI providers including OpenAI, Anthropic, Google, Groq, OpenRouter, and AWS Bedrock. This package provides a consistent API for making requests to different LLM providers while handling retries, streaming, and multimodal inputs.

Fully-local unified interface across multiple AI providers that includes:

  • 🛠️ Consistent function/tool calling across all providers
  • 💬 Consistent message alternation & system instructions
  • 🖼️ Image format & size normalization
  • 🔄 Automatic retries with configurable attempts
  • 📡 Streaming by default
  • ☁️ Cloud service providers supported (Azure, AWS Bedrock)
  • 🔌 Provider prefix strings for any model without waiting for package updates
npm install 190proof

Use any model from any provider with the provider:model-id format:

import { callWithRetries, GenericPayload } from "190proof";

const payload: GenericPayload = {
  model: "openai:gpt-4o-mini",
  messages: [
    {
      role: "user",
      content: "Tell me a joke.",
    },
  ],
};

const response = await callWithRetries("my-request-id", payload);
console.log(response.content);
js
import { callWithRetries, GenericPayload } from "190proof";

// OpenAI
const openaiPayload: GenericPayload = {
  model: "openai:gpt-5",
  messages: [{ role: "user", content: "Hello!" }],
};

// Anthropic
const claudePayload: GenericPayload = {
  model: "anthropic:claude-sonnet-4-5",
  messages: [{ role: "user", content: "Hello!" }],
};

// Google
const geminiPayload: GenericPayload = {
  model: "google:gemini-2.0-flash",
  messages: [{ role: "user", content: "Hello!" }],
};

// Groq
const groqPayload: GenericPayload = {
  model: "groq:llama-3.3-70b-versatile",
  messages: [{ role: "user", content: "Hello!" }],
};

// OpenRouter
const openRouterPayload: GenericPayload = {
  model: "openrouter:google/gemma-4-31b-it:free",
  messages: [{ role: "user", content: "Hello!" }],
};

const response = await callWithRetries("request-id", claudePayload);
js
const payload: GenericPayload = {
  model: "openai:gpt-4o",
  messages: [
    {
      role: "user",
      content: "What is the capital of France?",
    },
  ],
  functions: [
    {
      name: "get_country_capital",
      description: "Get the capital of a given country",
      parameters: {
        type: "object",
        properties: {
          country_name: {
            type: "string",
            description: "The name of the country",
          },
        },
        required: ["country_name"],
      },
    },
  ],
};

const response = await callWithRetries("function-call-example", payload);
// response.function_call contains { name: string, arguments: Record<string, any> }
js
const payload: GenericPayload = {
  model: "anthropic:claude-sonnet-4-5",
  messages: [
    {
      role: "user",
      content: "What's in this image?",
      files: [
        {
          mimeType: "image/jpeg",
          url: "https://example.com/image.jpg",
        },
      ],
    },
  ],
};

const response = await callWithRetries("image-example", payload);

How images reach the model depends on the provider. OpenAI, Anthropic, and Google get native image blocks. Groq is text-only: images degrade to an inline Image (url) text reference. OpenRouter sends OpenAI-style image_url content parts (the remote URL when present, else a data: URI) — but if the model has no vision-capable endpoints, OpenRouter rejects the request with a routing-layer 404, so the retry loop resends the payload with images degraded to the same inline text references Groq gets, and remembers the model (in-process, until restart) so later calls degrade up front. Messages without image attachments serialize identically either way.

const payload: GenericPayload = {
  model: "google:gemini-2.0-flash",
  messages: [
    {
      role: "system",
      content: "You are a helpful assistant that speaks in a friendly tone.",
    },
    {
      role: "user",
      content: "Tell me about yourself.",
    },
  ],
};

const response = await callWithRetries("system-message-example", payload);

Use parseModelString to see how a model string will be routed:

import { parseModelString } from "190proof";

parseModelString("openai:gpt-7");
// → { provider: "openai", modelId: "gpt-7" }

parseModelString("openrouter:org/model-name:free");
// → { provider: "openrouter", modelId: "org/model-name:free" }

The model string format is provider:model-id, where the provider prefix is one of:

Prefix Provider
openai OpenAI
anthropic Anthropic
google Google (Gemini)
groq Groq
openrouter OpenRouter

The prefix is stripped before sending to the API, so the model ID should be exactly what the provider expects (e.g. "openai:gpt-4o" sends "gpt-4o" to OpenAI).

These models are tested. You can use any model with the provider:model-id format.

  • openai:gpt-5

  • openai:gpt-5-mini

  • openai:gpt-4.1

  • openai:gpt-4.1-mini

  • openai:gpt-4.1-nano

  • openai:gpt-4o

  • openai:gpt-4o-mini

  • openai:o3-mini

  • openai:o1-preview

  • openai:o1-mini

  • anthropic:claude-opus-4-5

  • anthropic:claude-sonnet-4-5

  • anthropic:claude-haiku-4-5

  • anthropic:claude-opus-4-1

  • anthropic:claude-opus-4-20250514

  • anthropic:claude-sonnet-4-20250514

  • anthropic:claude-3-5-sonnet-20241022

  • anthropic:claude-3-5-haiku-20241022

  • google:gemini-3.1-flash-lite-preview

  • google:gemini-3-flash-preview

  • google:gemini-2.5-flash-preview-04-17

  • google:gemini-2.0-flash

  • google:gemini-2.0-flash-exp-image-generation

  • google:gemini-1.5-pro-latest

  • groq:llama-3.3-70b-versatile

  • groq:llama3-70b-8192

  • groq:qwen/qwen3-32b

  • groq:deepseek-r1-distill-llama-70b

  • openrouter:google/gemma-4-31b-it:free

  • openrouter:google/gemma-4-31b-it

Set the following environment variables for the providers you want to use:

OPENAI_API_KEY=your-openai-api-key

ANTHROPIC_API_KEY=your-anthropic-api-key

GEMINI_API_KEY=your-gemini-api-key

GROQ_API_KEY=your-groq-api-key

OPENROUTER_API_KEY=your-openrouter-api-key

AWS_ACCESS_KEY_ID=your-aws-access-key
AWS_SECRET_ACCESS_KEY=your-aws-secret-key

Main function to make requests to any supported AI provider.

  • identifier :string | string[] - Unique identifier for the request (used for logging)
  • payload :GenericPayload - Request payload containing model, messages, and optional functions
  • config :OpenAIConfig | AnthropicAIConfig - Optional configuration for the specific provider
  • retries :number - Number of retry attempts (default: 5)
  • chunkTimeoutMs :number - Timeout for streaming chunks in ms (default: 15000)

Optional per-request knobs live on payload (GenericPayload):

  • payload.requestTimeoutMs :number - Per-attempt HTTP timeout in ms (default: 120000), honored by every adapter — except streaming OpenRouter attempts, which it deliberately does NOT bound (see below). For OpenRouter's non-streaming transport the default is 180000.
  • payload.streaming :boolean - OpenRouter-only (default: true). Streams the completion over SSE. A streaming attempt is bounded by two independent timers instead ofrequestTimeoutMs :streamTimeoutMs (total wall clock, default 600000) and the per-useful-chunk stall timeout (chunkTimeoutMs argument, default 15000). A chunk is "useful" only if it advances content, reasoning, tool-call fragments, finish_reason, or usage — SSE comment keep-alives (: OPENROUTER PROCESSING ) and role-only deltas don't reset the stall timer, so a hung provider dies within one stall window while a healthy long generation can run to the total budget. Setstreaming: false for the old single-JSON-body transport.
  • payload.streamTimeoutMs :number - OpenRouter-only: total wall-clock budget per streaming attempt (default: 600000).
  • payload.streamDeadlineAt :number - OpenRouter-only: absolute deadline (epoch ms) for the whole callincluding retries — the caller's turn budget. Each attempt getsmin(streamTimeoutMs, deadline - now) , and once under 10s remain the call fails fast instead of starting a generation that cannot be delivered. Use it whenever the caller has its own timeout: a per-attempt budget alone is re-granted on every retry and can outlive that timeout.
  • payload.thinkingConfig :Record<string, unknown> - Google-only: forwarded verbatim asgenerationConfig.thinkingConfig on the Gemini request — e.g.{ thinkingBudget: 0 } to disable thinking,{ thinkingLevel: "HIGH" } on models that take a level. Ignored by all other adapters; shapes are model-specific and validated by Google, not the SDK.
  • payload.reasoningEffort :string - OpenAI and OpenRouter reasoning effort. Valid values are model-dependent (none /minimal /low /medium /high /xhigh /max ). OpenRouter: sent as the nestedreasoning: { effort } object — the canonical form, and the only one that acceptsmax (the flatreasoning_effort field caps atxhigh ). Direct OpenAI: sent flat asreasoning_effort ;max is rejected there, and reasoning-by-default models (the gpt-5.6 family) reject function tools on/chat/completions with a 400 unless this is explicitly"none" — their implicit default ismedium . Via OpenRouter the same models accept tools at any effort (OpenRouter fronts/v1/responses ), so omitting this runs them at their native default. Ignored by all other adapters.

When a streaming attempt is cut at its total deadline and prose has already arrived, the partial answer is returned with truncated: true on the response rather than discarded — those tokens were generated and billed, so throwing them away costs money and gives the user nothing. Surface such a reply as incomplete. Salvage never applies to tool-call turns (half-streamed arguments are unparseable JSON), to stalls (the provider died mid-thought), or to caller aborts. When nothing is salvageable, the discard is logged with an approximate token count — aborted attempts never receive OpenRouter's usage chunk, so that log line is the only record of the wasted spend.

OpenRouter retries also perform moderation eviction: a provider content-moderation rejection (e.g. "Upstream error from Alibaba: Output data may contain inappropriate content.") is deterministic for a given payload, so on the first one the refusing provider is removed from the request's provider preferences (ignore += slug, order -= slug) and every remaining attempt reroutes to the next provider. Non-moderation errors retry with unchanged preferences, and fallbackModel still applies if the whole pool refuses.

  • payload.signal :AbortSignal - Caller-supplied cancellation. When it aborts, the in-flight provider request is cancelled andcallWithRetriesrejects immediately — it does not retry or fall back (both the retry loop and the fallback branch bail onsignal.aborted ). Threaded to the underlying fetch/axios/SDK call of each provider.

Promise<ParsedResponseMessage>:

interface ParsedResponseMessage {
  role: "assistant";
  content: string | null;
  function_call: FunctionCall | null;
  function_calls: FunctionCall[];
  files: File[]; // For models that return files (e.g., image generation)
  // Who actually served the response: OpenRouter's upstream provider from the
  // response body (e.g. "Baidu"), or the SDK provider name ("anthropic", ...)
  // for direct providers. On fallback, reflects the model that answered.
  provider?: string;
  usage: {
    prompt_tokens: number;
    completion_tokens: number;
    total_tokens: number;
    // Reasoning/thinking tokens spent before the visible answer; currently
    // populated from Google's usageMetadata.thoughtsTokenCount.
    thoughts_tokens?: number;
  } | null; // null when streaming
}

Parses a model string into its provider and model ID components.

  • model :string - A model string in"provider:model-id" format

{ provider: Provider, modelId: string }

interface OpenAIConfig {
  service: "azure" | "openai";
  apiKey: string;
  /**
   * Optional base URL. Defaults to `https://api.openai.com/v1`. Set to point
   * at any OpenAI-compatible endpoint (e.g. a self-hosted proxy). The path
   * `/chat/completions` is appended automatically. Ignored for Azure.
   */
  baseUrl?: string;
  orgId?: string;
  modelConfigMap?: Record<
    string,
    {
      resource: string;
      deployment: string;
      apiVersion: string;
      apiKey: string;
      endpoint?: string;
    }
  >;
}

To talk to an OpenAI-compatible server instead of OpenAI itself:

await callWithRetries(
  "my-identifier",
  {
    model: "openai:gpt-4o-mini",
    messages: [{ role: "user", content: "hi" }],
  },
  {
    service: "openai",
    apiKey: process.env.SOME_SERVER_API_KEY,
    baseUrl: "https://your-proxy.example.com/v1",
  },
);
interface AnthropicAIConfig {
  service: "anthropic" | "bedrock";
}

ISC

── more in #ai-tools 4 stories · sorted by recency
── more on @190proof 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/190proof-opinionated…] indexed:0 read:8min 2026-09-11 ·