Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions JS/edgechains/arakoodev/src/ai/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,24 @@ export { GeminiAI } from "./lib/gemini/gemini.js";
export { LlamaAI } from "./lib/llama/llama.js";
export { RetellAI } from "./lib/retell-ai/retell.js";
export { RetellWebClient } from "./lib/retell-ai/retellWebClient.js";
export {
SmartRouter,
RouterError,
OpenAIAdapter,
AnthropicAdapter,
GoogleAdapter,
CohereAdapter,
resolveProvider,
} from "./lib/router/index.js";
export type {
SmartRouterOptions,
RouterRequest,
RouterResponse,
RouterMessage,
RouterRole,
RouterUsage,
ProviderAdapter,
ProviderKeys,
ProviderName,
ResolvedModel,
} from "./lib/router/index.js";
115 changes: 115 additions & 0 deletions JS/edgechains/arakoodev/src/ai/src/lib/router/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# SmartRouter — Unified LLM Provider Router

`SmartRouter` is EdgeChains' answer to Python's
[`litellm`](https://github.com/BerriAI/litellm): one TypeScript API that
dispatches chat-completion requests to the right provider based on the
model name, with a normalized request and response shape.

```ts
import { SmartRouter } from "@arakoodev/edgechains.js/ai";

const router = new SmartRouter({
openai: process.env.OPENAI_API_KEY,
anthropic: process.env.ANTHROPIC_API_KEY,
google: process.env.GOOGLE_API_KEY,
cohere: process.env.COHERE_API_KEY,
});

const res = await router.complete({
model: "gpt-4o", // or "claude-3-5-sonnet-20241022", "gemini-1.5-pro", "command-r-plus"
messages: [
{ role: "system", content: "You are concise." },
{ role: "user", content: "Say hello." },
],
temperature: 0.2,
max_tokens: 256,
});

console.log(res.content, res.provider, res.usage);
```

## How routing works

The provider is inferred from the **model prefix**:

| Prefix | Provider |
| -------------------------- | ----------- |
| `gpt-*`, `o1-*`, `o3-*` | `openai` |
| `claude-*` | `anthropic` |
| `gemini-*`, `models/gemini-*` | `google` |
| `command*` | `cohere` |
| `llama-*`, `llama3-*` | `llama` |

You can force a provider with the `"<provider>/<model>"` syntax,
e.g. `"openai/some-finetune"` or `"anthropic/claude-experimental-id"`.

If the prefix is unknown, the router falls back to `defaultProvider`
(when set) or throws `RouterError`.

## Normalized shapes

```ts
interface RouterRequest {
model: string;
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>;
temperature?: number;
max_tokens?: number;
}

interface RouterResponse {
content: string;
provider: "openai" | "anthropic" | "google" | "cohere" | "llama" | "unknown";
model: string;
usage: { input_tokens: number; output_tokens: number };
raw?: unknown; // full provider payload, kept for advanced use
}
```

System messages are pushed into each provider's correct slot
automatically (Anthropic's `system` field, Gemini's `systemInstruction`,
Cohere's `preamble`).

## Custom or local providers

Register an adapter at runtime — useful for Ollama, vLLM, a corporate
gateway, or any other provider:

```ts
import { SmartRouter, ProviderAdapter } from "@arakoodev/edgechains.js/ai";

const ollama: ProviderAdapter = {
name: "llama",
async complete(req) {
// call your local endpoint, return a RouterResponse
},
};

const router = new SmartRouter();
router.register("llama", ollama);
```

## Error handling

Every failure surfaces as `RouterError` with the originating provider,
the upstream HTTP status (when applicable), and the original error on
`.cause`:

```ts
import { RouterError } from "@arakoodev/edgechains.js/ai";

try {
await router.complete({ model: "gpt-4o", messages });
} catch (e) {
if (e instanceof RouterError) {
console.error(e.provider, e.status, e.message, e.cause);
}
}
```

## Scope of this MVP

- Built-in adapters for **OpenAI**, **Anthropic**, **Google Gemini**,
and **Cohere**.
- Llama / other providers can be plugged in via `register()`.
- Streaming, tool calling, and embeddings are intentionally out of
scope for this first PR and will land in follow-ups.
183 changes: 183 additions & 0 deletions JS/edgechains/arakoodev/src/ai/src/lib/router/SmartRouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import {
ProviderAdapter,
ProviderKeys,
ProviderName,
RouterRequest,
RouterResponse,
} from "./types.js";
import { resolveProvider } from "./providerResolver.js";
import { OpenAIAdapter } from "./adapters/openaiAdapter.js";
import { AnthropicAdapter } from "./adapters/anthropicAdapter.js";
import { GoogleAdapter } from "./adapters/googleAdapter.js";
import { CohereAdapter } from "./adapters/cohereAdapter.js";

export interface SmartRouterOptions extends ProviderKeys {
/**
* Optional provider used when the model prefix cannot be resolved.
* If unset, requests with unknown models throw ``RouterError``.
*/
defaultProvider?: ProviderName;
/**
* OpenAI organization id (forwarded to the OpenAI adapter only).
*/
openaiOrgId?: string;
/**
* Inject pre-built adapters (useful for tests / custom providers).
* Keys here take precedence over the auto-built adapters.
*/
adapters?: Partial<Record<ProviderName, ProviderAdapter>>;
}

/**
* Error thrown for router-level failures (unknown provider, missing key,
* upstream HTTP error). The original cause is preserved on ``.cause``
* so callers can introspect provider-specific error responses.
*/
export class RouterError extends Error {
public readonly provider: ProviderName;
public readonly status?: number;
public readonly cause?: unknown;

constructor(
message: string,
opts: { provider: ProviderName; status?: number; cause?: unknown } = {
provider: "unknown",
}
) {
super(message);
this.name = "RouterError";
this.provider = opts.provider;
this.status = opts.status;
this.cause = opts.cause;
}
}

/**
* Unified, provider-agnostic router for LLM chat completions.
*
* ``SmartRouter`` accepts a normalized {@link RouterRequest}, dispatches
* it to the appropriate provider (inferred from ``request.model`` or
* forced via the ``"provider/model"`` syntax), and returns a normalized
* {@link RouterResponse}. The goal is to mirror the ergonomics of
* Python's ``litellm`` package in TypeScript.
*
* ```ts
* const router = new SmartRouter({
* openai: process.env.OPENAI_API_KEY,
* anthropic: process.env.ANTHROPIC_API_KEY,
* });
*
* const res = await router.complete({
* model: "gpt-4o",
* messages: [{ role: "user", content: "Say hi" }],
* });
*
* console.log(res.content, res.provider, res.usage);
* ```
*/
export class SmartRouter {
private readonly adapters = new Map<ProviderName, ProviderAdapter>();
private readonly defaultProvider?: ProviderName;

constructor(options: SmartRouterOptions = {}) {
this.defaultProvider = options.defaultProvider;

// Auto-build adapters for every provider whose key is present.
const openaiKey = options.openai ?? process.env.OPENAI_API_KEY;
if (openaiKey) {
this.adapters.set(
"openai",
new OpenAIAdapter({ apiKey: openaiKey, orgId: options.openaiOrgId })
);
}
const anthropicKey = options.anthropic ?? process.env.ANTHROPIC_API_KEY;
if (anthropicKey) {
this.adapters.set("anthropic", new AnthropicAdapter({ apiKey: anthropicKey }));
}
const googleKey =
options.google ?? process.env.GOOGLE_API_KEY ?? process.env.GEMINI_API_KEY;
if (googleKey) {
this.adapters.set("google", new GoogleAdapter({ apiKey: googleKey }));
}
const cohereKey = options.cohere ?? process.env.COHERE_API_KEY;
if (cohereKey) {
this.adapters.set("cohere", new CohereAdapter({ apiKey: cohereKey }));
}

// Caller-provided adapters win, e.g. for tests or for ``llama``
// (which has no built-in adapter in this MVP yet).
if (options.adapters) {
for (const [name, adapter] of Object.entries(options.adapters)) {
if (adapter) this.adapters.set(name as ProviderName, adapter);
}
}
}

/**
* Register (or replace) an adapter for a given provider at runtime.
* Useful for plugging in custom providers (e.g. a local Ollama
* server) without forking the router.
*/
register(provider: ProviderName, adapter: ProviderAdapter): void {
this.adapters.set(provider, adapter);
}

/** Return ``true`` if an adapter is registered for ``provider``. */
supports(provider: ProviderName): boolean {
return this.adapters.has(provider);
}

/** List every provider currently routable from this instance. */
listProviders(): ProviderName[] {
return Array.from(this.adapters.keys());
}

/**
* Run a chat completion request. Throws {@link RouterError} when
* the provider cannot be resolved, no adapter is registered for the
* resolved provider, or the upstream call fails.
*/
async complete(req: RouterRequest): Promise<RouterResponse> {
if (!req || !req.model) {
throw new RouterError("RouterRequest.model is required", { provider: "unknown" });
}
if (!Array.isArray(req.messages) || req.messages.length === 0) {
throw new RouterError("RouterRequest.messages must be a non-empty array", {
provider: "unknown",
});
}

let { provider, model } = resolveProvider(req.model);
if (provider === "unknown") {
if (this.defaultProvider && this.adapters.has(this.defaultProvider)) {
provider = this.defaultProvider;
} else {
throw new RouterError(
`Unable to route model "${req.model}". Use "provider/model" or register an adapter.`,
{ provider: "unknown" }
);
}
}

const adapter = this.adapters.get(provider);
if (!adapter) {
throw new RouterError(
`No adapter registered for provider "${provider}". Did you pass the API key?`,
{ provider }
);
}

try {
return await adapter.complete({ ...req, model });
} catch (err: any) {
// Re-throw RouterErrors verbatim.
if (err instanceof RouterError) throw err;
const status = err?.response?.status;
const message =
err?.response?.data?.error?.message ||
err?.message ||
`Provider "${provider}" request failed`;
throw new RouterError(message, { provider, status, cause: err });
}
}
}
Loading
Loading