UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

304 lines • 16.5 kB
/** * createProvider — by-name factory for any built-in LLMProvider. * * Pattern: Abstract Factory (GoF) over the concrete provider factories. * Role: Convenience entry point. Useful for config-driven setups * where the provider is chosen at runtime (env var, feature * flag, tenant preference). * Emits: N/A. * * @example * const kind = (process.env.LLM_PROVIDER ?? 'anthropic') as ProviderKind; * const provider = createProvider({ * kind, * apiKey: process.env.LLM_API_KEY, * defaultModel: process.env.LLM_MODEL, * } as CreateProviderOptions); * * For provider-specific options (Bedrock region, Ollama baseUrl, Browser * apiUrl, etc.) construct the underlying factory directly — this * helper deliberately exposes only the common subset. */ import { mock } from './MockProvider.js'; import { anthropic } from './AnthropicProvider.js'; import { openai, azureOpenai } from './OpenAIProvider.js'; import { ollama } from './OllamaProvider.js'; import { foundry } from './FoundryProvider.js'; import { foundryLocal } from './FoundryLocalProvider.js'; import { bedrock } from './BedrockProvider.js'; import { gemini } from './GeminiProvider.js'; import { browserAnthropic, } from './BrowserAnthropicProvider.js'; import { browserOpenai } from './BrowserOpenAIProvider.js'; import { invokeModelGateway, } from './InvokeModelGatewayProvider.js'; /** * Build any built-in LLMProvider from a tagged options object. */ export function createProvider(options) { switch (options.kind) { case 'mock': return mock(options); case 'anthropic': return anthropic(options); case 'openai': return openai(options); case 'ollama': return ollama(options); case 'foundry': return foundry(options); case 'foundry-local': return foundryLocal(options); case 'bedrock': return bedrock(options); case 'gemini': return gemini(options); case 'browser-anthropic': return browserAnthropic(options); case 'browser-openai': return browserOpenai(options); case 'invoke-model-gateway': return invokeModelGateway(options); default: { // Exhaustiveness check — TypeScript will surface a missing case here. const _exhaustive = options; throw new Error(`createProvider: unknown kind ${JSON.stringify(_exhaustive.kind)}`); } } } /** * An environment variable counts as SET only when it carries a non-blank value. * * `AZURE_AI_MODEL_DEPLOYMENT_NAME=` — declared but blank, the usual way to * comment a variable out of a `.env` — is `''`, which `??` happily hands on as * a real value: the `MODEL_NAME` behind it is never consulted and the refusal * then denies a deployment was named while one sits in the environment. A * whitespace-only value is the same lie with a space in it. Trimming to * `undefined` makes the stated fallback chain real. * * Scoped to the `FOUNDRY_*` arms, which are new here. The older arms keep * their exact historical truthiness — changing what `AZURE_OPENAI_API_KEY=' '` * means is a separate, public behavior change and not this one's to make. */ function declared(raw) { const value = raw?.trim(); return value ? value : undefined; } /** * Resolve an `LLMProvider` from environment variables — drop your company's * values in `.env` and the right provider is configured automatically, with no * code branching. (Node only — reads `process.env`; the vendor SDK is lazy-loaded * only for the detected provider.) * * Detection order (first match wins): * 1. **Ollama (local)** — `OLLAMA_MODEL` names a model, e.g. `qwen3` * [+ `OLLAMA_HOST` for a runtime that isn't on localhost] * 2. **Foundry Local (local)** — `FOUNDRY_LOCAL_MODEL` names a model, e.g. * `qwen2.5-0.5b` [+ `FOUNDRY_LOCAL_ENDPOINT` | `FOUNDRY_LOCAL_BASE_URL` * when the service isn't on the docs' example port] * 3. **Foundry (project)** — `FOUNDRY_PROJECT_ENDPOINT` * + (`AZURE_AI_MODEL_DEPLOYMENT_NAME` | `MODEL_NAME`); the returned * `model` is the deployment you named. Auth is whatever `foundry()` * resolves — inside a hosted Foundry container that is managed identity * with zero further configuration. The endpoint with NO deployment named * does not match: this arm steps aside, every arm below gets its normal * turn, and Foundry's refusal is raised only if none of them resolves. * 4. **Azure OpenAI** — `AZURE_OPENAI_API_KEY` + (`AZURE_OPENAI_ENDPOINT` | * `OPENAI_BASE_URL`) + `AZURE_OPENAI_API_VERSION` * + (`AZURE_OPENAI_DEPLOYMENT` | `MODEL_NAME`). * `AZURE_OPENAI_ENDPOINT` and `OPENAI_BASE_URL` are two spellings of the * same resource root and reach the identical URL; the returned `model` is * the deployment you named. * 5. **Anthropic** — `ANTHROPIC_API_KEY` * 6. **OpenAI** — `OPENAI_API_KEY` * Otherwise throws (or returns the mock when `{ fallbackToMock: true }`). * * **Why the local models go first.** The credential arms trigger on a * CREDENTIAL, and credentials arrive in a shell by accident all the time — * a key exported in `.zshrc` two months ago for something else. `OLLAMA_MODEL` * and `FOUNDRY_LOCAL_MODEL` trigger on a NAME you had to choose and type, so * their presence is a declaration rather than a leftover: someone who writes * `OLLAMA_MODEL=qwen3` has said which model they want this run to use, and * honoring the cloud key instead would both ignore them and cost them money. * (`OLLAMA_HOST` alone is NOT a trigger — people export it just to run * Ollama, and it must not hijack an app that never asked for a local model. * The same holds for `FOUNDRY_LOCAL_ENDPOINT` / `FOUNDRY_LOCAL_BASE_URL`.) * * **Why Foundry sits between the names and the keys.** * `FOUNDRY_PROJECT_ENDPOINT` is a product-specific spelling nobody exports by * accident — and the one the hosted Foundry platform AUTO-INJECTS into its * containers — so where it is present AND a deployment is named, Foundry is * the declared destination and it outranks the lingering-credential arms * below. It still yields to the two local-model arms: precisely because the * platform can inject it, it must never beat a model name a person typed for * THIS run. * * **Why an endpoint alone never breaks your boot.** That same injection cuts * the other way. A hosted Foundry container whose agent calls Anthropic — the * shape this project's own hosting adapter ships for — receives the endpoint * whether or not anyone asked for Foundry INFERENCE, and it names no * deployment. So an endpoint with no deployment is not a throw: the arm holds * its refusal, and Azure, Anthropic, OpenAI and `fallbackToMock` all run * exactly as they did before this arm existed. The held refusal surfaces only * when the environment declares nothing else at all — where it is the most * useful message there is, because the endpoint is then the only clue. * * **No probing.** This function reads environment variables and nothing else. * It never opens a socket to see whether a daemon is up — its answer stays * deterministic, instant, and identical on a laptop and in CI. If `OLLAMA_MODEL` * is set and the daemon is down, you get the provider, and the refusal arrives * from the call itself with `ollama serve` in the message. * * @example * import { providerFromEnv } from 'agentfootprint'; * const { provider, model, kind } = providerFromEnv({ fallbackToMock: true }); * const agent = Agent.create({ provider, model }).build(); */ export function providerFromEnv(opts = {}) { const env = (typeof process !== 'undefined' ? process.env : {}); if (env.OLLAMA_MODEL) { return { provider: ollama(env.OLLAMA_MODEL, { ...(env.OLLAMA_HOST && { baseUrl: env.OLLAMA_HOST }), }), model: env.OLLAMA_MODEL, kind: 'ollama', }; } const foundryLocalModel = declared(env.FOUNDRY_LOCAL_MODEL); if (foundryLocalModel) { // Same law as OLLAMA_MODEL: a model NAME someone typed for a LOCAL runtime // is a declaration, not a leftover. It outranks even FOUNDRY_PROJECT_ENDPOINT // below — the hosted platform AUTO-INJECTS that one, and an injected // variable must never beat a hand-typed model name. // `declared()` throughout: a blanked-out FOUNDRY_LOCAL_ENDPOINT must fall // through to FOUNDRY_LOCAL_BASE_URL, not shadow it with an empty address. const localEndpoint = declared(env.FOUNDRY_LOCAL_ENDPOINT) ?? declared(env.FOUNDRY_LOCAL_BASE_URL); return { provider: foundryLocal(foundryLocalModel, { ...(localEndpoint && { endpoint: localEndpoint }), }), model: foundryLocalModel, kind: 'foundry-local', }; } // Held, never thrown from inside the arm — raised at the bottom of this // function only if no arm below resolves. See `foundryRefusal` there. let foundryRefusal; const foundryEndpoint = declared(env.FOUNDRY_PROJECT_ENDPOINT); if (foundryEndpoint) { // A product-specific spelling nobody exports by accident — and the one the // hosted Foundry platform injects into its containers — so a NAMED // deployment here outranks the lingering-credential arms below: Foundry IS // the declared destination. // MODEL_NAME is the GENERIC deployment spelling, and the Azure arm below // has read it since long before this arm existed. So it feeds Foundry only // when no bootable Azure config is present: an env that resolved to // azure-openai on 9.73.0 keeps resolving to azure-openai when the hosted // platform injects FOUNDRY_PROJECT_ENDPOINT next to it. Choosing Foundry // over a working Azure config takes the product-specific spelling, // AZURE_AI_MODEL_DEPLOYMENT_NAME — a deliberate declaration, not a leftover. const azureBootable = Boolean(env.AZURE_OPENAI_API_KEY && (env.AZURE_OPENAI_ENDPOINT ?? env.OPENAI_BASE_URL)); const foundryDeployment = declared(env.AZURE_AI_MODEL_DEPLOYMENT_NAME) ?? (azureBootable ? undefined : declared(env.MODEL_NAME)); if (foundryDeployment) { return { provider: foundry({ projectEndpoint: foundryEndpoint, deployment: foundryDeployment, }), // The DEPLOYMENT string, never the kind label `'foundry'` — the Azure // arm's law, for the same reason: this value is handed to // `Agent.create({ provider, model })` and travels into traces, budgets // and logs, where a kind label names nothing a reader could look up. model: foundryDeployment, kind: 'foundry', }; } // The endpoint says Foundry; nothing says WHICH deployment. Foundry routes // by deployment, so there is no default to guess at — but this is NOT the // Azure arm's situation, and it does not get the Azure arm's immediate // throw. The Azure arm is entered by two variables a person exported on // purpose; this one is entered by a variable the hosted platform injects // into every container it runs, including containers whose agent calls // Anthropic and never asked for Foundry inference. Throwing here would // turn a minor upgrade into a startup crash for them, and would pre-empt // `fallbackToMock` — the documented escape hatch — as well. // // So: HOLD the refusal and fall through. Every arm below behaves exactly // as it did before this arm existed. If one of them resolves, that is the // answer. If none does, the held refusal is thrown instead of the generic // "no provider declared" catalogue, because the endpoint IS a declaration // and naming the single variable it is missing is the more useful message. foundryRefusal = new Error('providerFromEnv: a Foundry project endpoint is set, but no model deployment is named.\n' + ' Foundry routes by DEPLOYMENT (its name for the model), and there is no default.\n' + ' Fix: set AZURE_AI_MODEL_DEPLOYMENT_NAME (or MODEL_NAME) to your deployment id, e.g. gpt-4o-128k.\n' + ' (Nothing else in this environment declares a provider either — an Azure, Anthropic or\n' + ' OpenAI credential, or { fallbackToMock: true }, would have been used instead of this refusal.)'); } const azureEndpoint = env.AZURE_OPENAI_ENDPOINT ?? env.OPENAI_BASE_URL; if (env.AZURE_OPENAI_API_KEY && azureEndpoint) { const azureDeployment = env.AZURE_OPENAI_DEPLOYMENT ?? env.MODEL_NAME; if (!azureDeployment) { // The credentials say Azure; nothing says WHICH deployment. Azure routes // by deployment, so there is no default to fall back to — refuse here, // naming the variable to set, rather than guess or send a kind label. throw new Error('providerFromEnv: Azure credentials are set, but no deployment is named.\n' + ' Azure routes by DEPLOYMENT (its name for the model), and there is no default.\n' + ' Fix: set AZURE_OPENAI_DEPLOYMENT (or MODEL_NAME) to your deployment id, e.g. gpt-4o-128k.'); } return { provider: azureOpenai({ endpoint: azureEndpoint, apiKey: env.AZURE_OPENAI_API_KEY, ...(env.AZURE_OPENAI_API_VERSION && { apiVersion: env.AZURE_OPENAI_API_VERSION }), deployment: azureDeployment, }), // The DEPLOYMENT, not the kind label `'azure'`. This value is handed to // `Agent.create({ provider, model })` and from there into traces, budgets // and logs, where a kind label reads like a model id and names nothing a // reader could look up. (The other arms return `LLM_MODEL` when it is set // and otherwise a shorthand the adapter itself resolves to its declared // default model — there is no user-named model to prefer. Azure has one, // sitting right there in AZURE_OPENAI_DEPLOYMENT / MODEL_NAME.) model: azureDeployment, kind: 'azure-openai', }; } if (env.ANTHROPIC_API_KEY) { return { provider: anthropic({ apiKey: env.ANTHROPIC_API_KEY }), model: env.LLM_MODEL ?? 'anthropic', kind: 'anthropic', }; } if (env.OPENAI_API_KEY) { return { provider: openai({ apiKey: env.OPENAI_API_KEY }), model: env.LLM_MODEL ?? 'openai', kind: 'openai', }; } if (opts.fallbackToMock) { return { provider: mock({ reply: 'mock reply (no provider env set)' }), model: 'mock', kind: 'mock', }; } // A held Foundry refusal outranks the generic catalogue: the environment did // declare a destination, and naming the one variable it is missing beats a // list of six providers the reader has already walked past. if (foundryRefusal) throw foundryRefusal; throw new Error('providerFromEnv: no provider declared in the environment. Set one of:\n' + ' • Ollama: OLLAMA_MODEL (a local model, e.g. qwen3 — free, no API key)\n' + ' • Foundry Local: FOUNDRY_LOCAL_MODEL (a local model, e.g. qwen2.5-0.5b — free, no API key)\n' + ' • Foundry: FOUNDRY_PROJECT_ENDPOINT (the project endpoint — auto-injected in hosted\n' + ' containers) + (AZURE_AI_MODEL_DEPLOYMENT_NAME | MODEL_NAME)\n' + ' • Azure: AZURE_OPENAI_API_KEY + (AZURE_OPENAI_ENDPOINT | OPENAI_BASE_URL —\n' + ' either spelling of the resource root, e.g. https://my-co.openai.azure.com)\n' + ' + AZURE_OPENAI_API_VERSION + (AZURE_OPENAI_DEPLOYMENT | MODEL_NAME)\n' + ' • Anthropic: ANTHROPIC_API_KEY\n' + ' • OpenAI: OPENAI_API_KEY\n' + ' …or call providerFromEnv({ fallbackToMock: true }).'); } //# sourceMappingURL=createProvider.js.map