{"slug": "190proof-opinionated-unified-interface-to-interact-with-multiple-ai-providers", "title": "190proof opinionated unified interface to interact with multiple AI providers", "summary": "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.", "body_md": "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.\n\nFully-local unified interface across multiple AI providers that includes:\n\n- 🛠️ Consistent function/tool calling across all providers\n- 💬 Consistent message alternation & system instructions\n- 🖼️ Image format & size normalization\n- 🔄 Automatic retries with configurable attempts\n- 📡 Streaming by default\n- ☁️ Cloud service providers supported (Azure, AWS Bedrock)\n- 🔌 Provider prefix strings for any model without waiting for package updates\n\n```\nnpm install 190proof\n```\n\nUse any model from any provider with the `provider:model-id` format:\n\n``` js\nimport { callWithRetries, GenericPayload } from \"190proof\";\n\nconst payload: GenericPayload = {\n  model: \"openai:gpt-4o-mini\",\n  messages: [\n    {\n      role: \"user\",\n      content: \"Tell me a joke.\",\n    },\n  ],\n};\n\nconst response = await callWithRetries(\"my-request-id\", payload);\nconsole.log(response.content);\njs\nimport { callWithRetries, GenericPayload } from \"190proof\";\n\n// OpenAI\nconst openaiPayload: GenericPayload = {\n  model: \"openai:gpt-5\",\n  messages: [{ role: \"user\", content: \"Hello!\" }],\n};\n\n// Anthropic\nconst claudePayload: GenericPayload = {\n  model: \"anthropic:claude-sonnet-4-5\",\n  messages: [{ role: \"user\", content: \"Hello!\" }],\n};\n\n// Google\nconst geminiPayload: GenericPayload = {\n  model: \"google:gemini-2.0-flash\",\n  messages: [{ role: \"user\", content: \"Hello!\" }],\n};\n\n// Groq\nconst groqPayload: GenericPayload = {\n  model: \"groq:llama-3.3-70b-versatile\",\n  messages: [{ role: \"user\", content: \"Hello!\" }],\n};\n\n// OpenRouter\nconst openRouterPayload: GenericPayload = {\n  model: \"openrouter:google/gemma-4-31b-it:free\",\n  messages: [{ role: \"user\", content: \"Hello!\" }],\n};\n\nconst response = await callWithRetries(\"request-id\", claudePayload);\njs\nconst payload: GenericPayload = {\n  model: \"openai:gpt-4o\",\n  messages: [\n    {\n      role: \"user\",\n      content: \"What is the capital of France?\",\n    },\n  ],\n  functions: [\n    {\n      name: \"get_country_capital\",\n      description: \"Get the capital of a given country\",\n      parameters: {\n        type: \"object\",\n        properties: {\n          country_name: {\n            type: \"string\",\n            description: \"The name of the country\",\n          },\n        },\n        required: [\"country_name\"],\n      },\n    },\n  ],\n};\n\nconst response = await callWithRetries(\"function-call-example\", payload);\n// response.function_call contains { name: string, arguments: Record<string, any> }\njs\nconst payload: GenericPayload = {\n  model: \"anthropic:claude-sonnet-4-5\",\n  messages: [\n    {\n      role: \"user\",\n      content: \"What's in this image?\",\n      files: [\n        {\n          mimeType: \"image/jpeg\",\n          url: \"https://example.com/image.jpg\",\n        },\n      ],\n    },\n  ],\n};\n\nconst response = await callWithRetries(\"image-example\", payload);\n```\n\nHow images reach the model depends on the provider. OpenAI, Anthropic, and\nGoogle get native image blocks. Groq is text-only: images degrade to an inline\n`Image (url)` text reference. OpenRouter sends OpenAI-style `image_url` content\nparts (the remote URL when present, else a `data:` URI) — but if the model has\nno vision-capable endpoints, OpenRouter rejects the request with a\nrouting-layer 404, so the retry loop resends the payload with images degraded\nto the same inline text references Groq gets, and remembers the model\n(in-process, until restart) so later calls degrade up front. Messages without\nimage attachments serialize identically either way.\n\n``` js\nconst payload: GenericPayload = {\n  model: \"google:gemini-2.0-flash\",\n  messages: [\n    {\n      role: \"system\",\n      content: \"You are a helpful assistant that speaks in a friendly tone.\",\n    },\n    {\n      role: \"user\",\n      content: \"Tell me about yourself.\",\n    },\n  ],\n};\n\nconst response = await callWithRetries(\"system-message-example\", payload);\n```\n\nUse `parseModelString` to see how a model string will be routed:\n\n``` js\nimport { parseModelString } from \"190proof\";\n\nparseModelString(\"openai:gpt-7\");\n// → { provider: \"openai\", modelId: \"gpt-7\" }\n\nparseModelString(\"openrouter:org/model-name:free\");\n// → { provider: \"openrouter\", modelId: \"org/model-name:free\" }\n```\n\nThe model string format is `provider:model-id`, where the provider prefix is one of:\n\n| Prefix | Provider | \n|---|---|\n| `openai` | OpenAI | \n| `anthropic` | Anthropic | \n| `google` | Google (Gemini) | \n| `groq` | Groq | \n| `openrouter` | OpenRouter | \n\nThe 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).\n\nThese models are tested. You can use any model with the `provider:model-id` format.\n\n- `openai:gpt-5`\n- `openai:gpt-5-mini`\n- `openai:gpt-4.1`\n- `openai:gpt-4.1-mini`\n- `openai:gpt-4.1-nano`\n- `openai:gpt-4o`\n- `openai:gpt-4o-mini`\n- `openai:o3-mini`\n- `openai:o1-preview`\n- `openai:o1-mini`\n\n- `anthropic:claude-opus-4-5`\n- `anthropic:claude-sonnet-4-5`\n- `anthropic:claude-haiku-4-5`\n- `anthropic:claude-opus-4-1`\n- `anthropic:claude-opus-4-20250514`\n- `anthropic:claude-sonnet-4-20250514`\n- `anthropic:claude-3-5-sonnet-20241022`\n- `anthropic:claude-3-5-haiku-20241022`\n\n- `google:gemini-3.1-flash-lite-preview`\n- `google:gemini-3-flash-preview`\n- `google:gemini-2.5-flash-preview-04-17`\n- `google:gemini-2.0-flash`\n- `google:gemini-2.0-flash-exp-image-generation`\n- `google:gemini-1.5-pro-latest`\n\n- `groq:llama-3.3-70b-versatile`\n- `groq:llama3-70b-8192`\n- `groq:qwen/qwen3-32b`\n- `groq:deepseek-r1-distill-llama-70b`\n\n- `openrouter:google/gemma-4-31b-it:free`\n- `openrouter:google/gemma-4-31b-it`\n\nSet the following environment variables for the providers you want to use:\n\n```\n# OpenAI\nOPENAI_API_KEY=your-openai-api-key\n\n# Anthropic\nANTHROPIC_API_KEY=your-anthropic-api-key\n\n# Google\nGEMINI_API_KEY=your-gemini-api-key\n\n# Groq\nGROQ_API_KEY=your-groq-api-key\n\n# OpenRouter\nOPENROUTER_API_KEY=your-openrouter-api-key\n\n# AWS Bedrock (for Anthropic via Bedrock)\nAWS_ACCESS_KEY_ID=your-aws-access-key\nAWS_SECRET_ACCESS_KEY=your-aws-secret-key\n```\n\nMain function to make requests to any supported AI provider.\n\n- `identifier` :`string | string[]` - Unique identifier for the request (used for logging)\n- `payload` :`GenericPayload` - Request payload containing model, messages, and optional functions\n- `config` :`OpenAIConfig | AnthropicAIConfig` - Optional configuration for the specific provider\n- `retries` :`number` - Number of retry attempts (default: 5)\n- `chunkTimeoutMs` :`number` - Timeout for streaming chunks in ms (default: 15000)\n\nOptional per-request knobs live on `payload` (`GenericPayload`):\n\n- `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.\n- `payload.streaming` :`boolean` - OpenRouter-only (default: true). Streams the completion over SSE. A streaming attempt is bounded by two independent timers instead of`requestTimeoutMs` :`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. Set`streaming: false` for the old single-JSON-body transport.\n- `payload.streamTimeoutMs` :`number` - OpenRouter-only: total wall-clock budget per streaming attempt (default: 600000).\n- `payload.streamDeadlineAt` :`number` - OpenRouter-only: absolute deadline (epoch ms) for the whole call**including retries** — the caller's turn budget. Each attempt gets`min(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.\n- `payload.thinkingConfig` :`Record<string, unknown>` - Google-only: forwarded verbatim as`generationConfig.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.\n- `payload.reasoningEffort` :`string` - OpenAI and OpenRouter reasoning effort. Valid values are model-dependent (`none` /`minimal` /`low` /`medium` /`high` /`xhigh` /`max` ). OpenRouter: sent as the nested`reasoning: { effort }` object — the canonical form, and the only one that accepts`max` (the flat`reasoning_effort` field caps at`xhigh` ). Direct OpenAI: sent flat as`reasoning_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 is`medium` . 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.\n\nWhen 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.\n\nOpenRouter 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.\n\n- `payload.signal` :`AbortSignal` - Caller-supplied cancellation. When it aborts, the in-flight provider request is cancelled and`callWithRetries`**rejects immediately — it does not retry or fall back** (both the retry loop and the fallback branch bail on`signal.aborted` ). Threaded to the underlying fetch/axios/SDK call of each provider.\n\n`Promise<ParsedResponseMessage>`:\n\n```\ninterface ParsedResponseMessage {\n  role: \"assistant\";\n  content: string | null;\n  function_call: FunctionCall | null;\n  function_calls: FunctionCall[];\n  files: File[]; // For models that return files (e.g., image generation)\n  // Who actually served the response: OpenRouter's upstream provider from the\n  // response body (e.g. \"Baidu\"), or the SDK provider name (\"anthropic\", ...)\n  // for direct providers. On fallback, reflects the model that answered.\n  provider?: string;\n  usage: {\n    prompt_tokens: number;\n    completion_tokens: number;\n    total_tokens: number;\n    // Reasoning/thinking tokens spent before the visible answer; currently\n    // populated from Google's usageMetadata.thoughtsTokenCount.\n    thoughts_tokens?: number;\n  } | null; // null when streaming\n}\n```\n\nParses a model string into its provider and model ID components.\n\n- `model` :`string` - A model string in`\"provider:model-id\"` format\n\n`{ provider: Provider, modelId: string }`\n\n```\ninterface OpenAIConfig {\n  service: \"azure\" | \"openai\";\n  apiKey: string;\n  /**\n   * Optional base URL. Defaults to `https://api.openai.com/v1`. Set to point\n   * at any OpenAI-compatible endpoint (e.g. a self-hosted proxy). The path\n   * `/chat/completions` is appended automatically. Ignored for Azure.\n   */\n  baseUrl?: string;\n  orgId?: string;\n  modelConfigMap?: Record<\n    string,\n    {\n      resource: string;\n      deployment: string;\n      apiVersion: string;\n      apiKey: string;\n      endpoint?: string;\n    }\n  >;\n}\n```\n\nTo talk to an OpenAI-compatible server instead of OpenAI itself:\n\n```\nawait callWithRetries(\n  \"my-identifier\",\n  {\n    model: \"openai:gpt-4o-mini\",\n    messages: [{ role: \"user\", content: \"hi\" }],\n  },\n  {\n    service: \"openai\",\n    apiKey: process.env.SOME_SERVER_API_KEY,\n    baseUrl: \"https://your-proxy.example.com/v1\",\n  },\n);\ninterface AnthropicAIConfig {\n  service: \"anthropic\" | \"bedrock\";\n}\n```\n\nISC", "url": "https://wpnews.pro/news/190proof-opinionated-unified-interface-to-interact-with-multiple-ai-providers", "canonical_source": "https://github.com/0xmmo/190proof", "published_at": "2026-09-11 14:01:57+00:00", "updated_at": "2026-09-11 14:15:50.227769+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "large-language-models", "ai-products"], "entities": ["190proof", "OpenAI", "Anthropic", "Google", "Groq", "OpenRouter", "AWS Bedrock", "npm"], "alternates": {"html": "https://wpnews.pro/news/190proof-opinionated-unified-interface-to-interact-with-multiple-ai-providers", "markdown": "https://wpnews.pro/news/190proof-opinionated-unified-interface-to-interact-with-multiple-ai-providers.md", "text": "https://wpnews.pro/news/190proof-opinionated-unified-interface-to-interact-with-multiple-ai-providers.txt", "jsonld": "https://wpnews.pro/news/190proof-opinionated-unified-interface-to-interact-with-multiple-ai-providers.jsonld"}}