agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
279 lines • 16 kB
JavaScript
;
/**
* FoundryProvider — Microsoft Foundry (Azure AI Foundry) projects over the
* api-version-free v1 OpenAI-compatible inference route.
*
* Pattern: Adapter (GoF) + Ports-and-Adapters (Cockburn 2005), composed —
* a thin vendor file over `openai()`'s machinery, the exact
* `azureOpenai()` shape.
* Role: Outer ring — owns EVERY Foundry spelling (the project-endpoint
* shape, the env-var names, the token audience, the `/openai/v1`
* derivation) so the `LLMProvider` port and the rest of the library
* never learn one. Knows nothing about agents, recorders, or
* compositions.
* Emits: N/A.
*
* ─── Why this exists ─────────────────────────────────────────────────
*
* This is the JS answer to Microsoft's
* `FoundryChatClient(project_endpoint, model, credential)`: point at a
* Foundry PROJECT, name a DEPLOYMENT, hand over a credential, done. The
* doc-verified facts it is built on (learn.microsoft.com, Aug 2026):
*
* • A Foundry project endpoint
* (`https://{account}.services.ai.azure.com/api/projects/{project}`)
* itself serves the v1 inference route by simple suffixing: `+ /openai/v1`.
* No `api-version` query, no deployment-scoped path.
* • The wire's `model` field carries the DEPLOYMENT name — Foundry's
* "model" is a deployment, exactly as on classic Azure OpenAI.
* • Auth is `Authorization: Bearer <Entra token or api key>` — the v1
* route accepts a key as a Bearer too.
*
* Because that route IS current OpenAI wire, this file is `openai({ baseURL,
* legacyEndpoint: false, … })` plus Foundry's spellings — the same
* composition `azureOpenai()` uses, with the modern dialect declared instead
* of implied away by the custom `baseURL`.
*
* ─── Auth: three doors, one refused ambiguity ────────────────────────
*
* • `credential` — any `@azure/identity` credential (duck-typed; the SDK
* is never imported for this door). Tokens are minted per request
* through `openai()`'s credential-callback seam.
* • `apiKey` — a static key, or a callback re-read per request.
* • NEITHER — `new DefaultAzureCredential()` via the optional
* `@azure/identity` peer: the platform's own blessed zero-config pattern
* inside a hosted Foundry container (the platform injects
* `FOUNDRY_PROJECT_ENDPOINT` and gives the agent a managed identity, so
* `foundry()` with no arguments is a complete configuration there).
* • BOTH `credential` and `apiKey` — refused by name. Two credentials is a
* config bug, not extra security.
*
* ─── Ceilings (stated, not worked around) ────────────────────────────
*
* • DEPLOYMENT NAMES HIDE THE MODEL. o-series auto-detection cannot work on
* an arbitrary deployment name, so `reasoning` must be declared — the same
* rationale, word for word, as `azureOpenai()`.
* • INFERENCE ONLY. The token audience here is the data plane
* ({@link AZURE_AI_SCOPE}); the ARM control plane is a different audience
* and a different job — see `entraIdentity()` in the identity adapters.
* • Everything `openai()` does not do (multi-modal, JSON-mode), this does
* not do either — it is the same machinery.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.foundry = exports.foundryInferenceUrl = void 0;
const lazyRequire_js_1 = require("../../lib/lazyRequire.js");
const OpenAIProvider_js_1 = require("./OpenAIProvider.js");
const azure_js_1 = require("../identity/azure.js");
const linearText_js_1 = require("../../lib/linearText.js");
/** Every refusal this file authors opens with this name. */
const ADAPTER = 'foundry';
/** The endpoint shape refusals point at — quoted, never guessed at. */
const ENDPOINT_SHAPE = 'https://{account}.services.ai.azure.com/api/projects/{project}';
// ─── URL derivation ─────────────────────────────────────────────────
/**
* Project endpoint → the base URL the v1 inference route serves.
*
* `https://acct.services.ai.azure.com/api/projects/proj`
* → `https://acct.services.ai.azure.com/api/projects/proj/openai/v1`
* …with trailing slashes → the same
* …already ending in `/openai/v1` → the same (idempotent — `azureUrl.ts`'s
* rule, applied to Foundry's suffix)
*
* Validation lives HERE, beside the derivation, so there is exactly ONE owner
* of what a Foundry endpoint looks like: it must be `https://` and it must
* contain `/api/projects/` — anything else is some OTHER Azure endpoint
* (a resource root, an ARM URL) that would 404 or 401 far from the typo.
* Endpoints are NOT secrets, so a malformed one is echoed back: seeing what
* arrived is the fastest fix. The one carve-out is cleartext to LOOPBACK
* (127.0.0.1 / localhost / [::1]) — that is how the wire tests drive the
* REAL SDK against a local fake, and refusing bytes that cannot leave the
* machine would buy no safety.
*
* A `?` or `#` is refused too, and for a reason particular to a SUFFIXING
* derivation: `…/api/projects/p?x=1` + `/openai/v1` is
* `…/api/projects/p?x=1/openai/v1`, where the route lives entirely inside the
* query string. That URL is well-formed, constructs without complaint, and
* 404s on every request — the exact "late and far from the typo" failure this
* validator exists to convert into an early named one. The v1 inference route
* takes no query parameters at all (that is the point of it: no
* `api-version`), so there is nothing legitimate to preserve.
*/
function foundryInferenceUrl(projectEndpoint) {
const trimmed = (0, linearText_js_1.trimTrailing)(projectEndpoint, linearText_js_1.isSlash);
const loopback = /^http:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?\//i.test(trimmed);
const shapeOk = (/^https:\/\//i.test(trimmed) || loopback) && trimmed.includes('/api/projects/');
if (!shapeOk) {
throw new Error(`${ADAPTER}: \`projectEndpoint\` does not look like a Foundry project endpoint: ` +
`${projectEndpoint}\n` +
` Expected: ${ENDPOINT_SHAPE}\n` +
' Fix: copy the project endpoint from the Foundry portal (project Overview page), or ' +
'run where the platform injects FOUNDRY_PROJECT_ENDPOINT.');
}
// AFTER the shape check: an endpoint that is wrong in both ways gets the
// more fundamental diagnosis first.
const marker = trimmed.includes('?') ? '?' : trimmed.includes('#') ? '#' : undefined;
if (marker !== undefined) {
throw new Error(`${ADAPTER}: \`projectEndpoint\` carries a \`${marker}\`, and this route is built by ` +
`SUFFIXING the endpoint: ${projectEndpoint}\n` +
` Appending \`/openai/v1\` would put the entire inference path INSIDE the ` +
`${marker === '?' ? 'query string' : 'fragment'} — a URL that constructs fine and then ` +
`404s on every request, far from the typo.\n` +
` Expected: ${ENDPOINT_SHAPE}\n` +
` Fix: drop everything from the \`${marker}\` onward — the v1 route is ` +
'api-version-free and takes no query parameters.');
}
return trimmed.endsWith('/openai/v1') ? trimmed : `${trimmed}/openai/v1`;
}
exports.foundryInferenceUrl = foundryInferenceUrl;
// ─── Factory ────────────────────────────────────────────────────────
/**
* Which roles this wire carries inside `messages` — `inner`'s wire, restated
* rather than inherited by accident (azureOpenai's stance): the v1 route is
* OpenAI's chat-completions shape, where the system prompt rides as a message
* (as `developer` on reasoning deployments), so all three roles survive.
*/
const CARRIES_IN_MESSAGES = Object.freeze(['system', 'user', 'assistant']);
/**
* Build an `LLMProvider` for a **Microsoft Foundry project**.
*
* Inside a hosted Foundry container this is a COMPLETE configuration:
*
* @example
* import { foundry } from 'agentfootprint/providers';
*
* const agent = Agent.create({
* provider: foundry(), // endpoint injected, managed identity signs
* model: 'foundry', // → the configured deployment
* }).build();
*
* Anywhere else, name the project and the deployment (and sign in, or pass a
* credential):
*
* @example
* const provider = foundry({
* projectEndpoint: 'https://my-acct.services.ai.azure.com/api/projects/my-proj',
* deployment: 'gpt-4o-128k',
* credential: new DefaultAzureCredential(),
* });
*
* The request's `model` is the DEPLOYMENT name: the shorthand `'foundry'`
* resolves to the configured default, and a concrete deployment id passes
* through untouched (so one provider can target several deployments — the
* azureOpenai precedent).
*/
function foundry(options = {}) {
const projectEndpoint = options.projectEndpoint ?? process.env.FOUNDRY_PROJECT_ENDPOINT;
if (!projectEndpoint) {
throw new Error(`${ADAPTER}: a \`projectEndpoint\` is required — the shape is ${ENDPOINT_SHAPE}.\n` +
' Hosted Foundry containers have it injected as FOUNDRY_PROJECT_ENDPOINT; anywhere ' +
'else, copy it from the Foundry portal.\n' +
' Fix: pass `projectEndpoint`, or set FOUNDRY_PROJECT_ENDPOINT.');
}
const baseURL = foundryInferenceUrl(projectEndpoint);
const deployment = options.deployment ?? process.env.AZURE_AI_MODEL_DEPLOYMENT_NAME ?? process.env.MODEL_NAME;
if (!deployment) {
throw new Error(`${ADAPTER}: a \`deployment\` is required — Foundry's "model" is the DEPLOYMENT name.\n` +
' Fix: pass `deployment`, or set AZURE_AI_MODEL_DEPLOYMENT_NAME (the azd scaffolding ' +
'convention) or MODEL_NAME.');
}
const apiKey = resolveFoundryAuth(options);
// Reuse ALL of openai()'s logic. The v1 route is CURRENT OpenAI wire, so the
// custom baseURL must not demote it to the legacy dialect: `legacyEndpoint:
// false` is the whole reason that dial is public — max_completion_tokens,
// stream_options.include_usage, and declared forced tool choice.
const inner = (0, OpenAIProvider_js_1.openai)({
baseURL,
legacyEndpoint: false,
...(apiKey !== undefined && { apiKey }),
defaultModel: deployment,
...(options.reasoning !== undefined && { reasoning: options.reasoning }),
...(options.defaultMaxTokens !== undefined && { defaultMaxTokens: options.defaultMaxTokens }),
...(options._client && { _client: options._client }),
});
// Foundry's "model" IS the deployment — rewrite the shorthand to it; a
// concrete id passes through (so you can target multiple deployments).
const withDeployment = (req) => req.model === 'foundry' ? { ...req, model: deployment } : req;
return {
name: 'foundry',
// The wire is `inner`'s, so the capability is `inner`'s. Re-stating it
// rather than inheriting by accident: this factory builds a fresh object,
// and a dropped capability silently becomes the user/assistant floor.
carriesInMessages: CARRIES_IN_MESSAGES,
...(inner.carriesForcedToolChoice !== undefined && {
carriesForcedToolChoice: inner.carriesForcedToolChoice,
}),
...(inner.supportsForcedToolChoice !== undefined && {
supportsForcedToolChoice: inner.supportsForcedToolChoice,
}),
...(inner.promptCaching !== undefined && { promptCaching: inner.promptCaching }),
...(inner.thinkingHandler !== undefined && { thinkingHandler: inner.thinkingHandler }),
// `hooks` is FORWARDED, not dropped — see LLMCallHooks in provider-contract/types.ts.
complete: (req, hooks) => inner.complete(withDeployment(req), hooks),
...(inner.stream && {
stream: (req, hooks) => inner.stream(withDeployment(req), hooks),
}),
};
}
exports.foundry = foundry;
// ─── Auth resolution ────────────────────────────────────────────────
/**
* Options → what `openai()`'s `apiKey` slot receives. Three doors and one
* refusal, resolved in one place so the factory above reads as composition.
*
* The `credential` door returns a CALLBACK, which is `openai()`'s per-request
* credential seam: called before every request, and the SDK client is rebuilt
* only when the returned STRING changes. That is exactly right for
* MSAL-cached tokens — the cache serves the same string until it refreshes,
* so a cached token costs one function call and zero client rebuilds, and a
* refreshed one becomes a new client instead of a 401.
*/
function resolveFoundryAuth(options) {
// Two credentials is not "extra secure" — whichever one this factory
// silently preferred would be the one the consumer did not think was in use.
if (options.credential && options.apiKey) {
throw new Error(`${ADAPTER}: both \`credential\` and \`apiKey\` were given, and they are two answers to ` +
'the same question — which identity signs the request.\n' +
' Fix: pass exactly one — `credential` (keyless Entra ID) or `apiKey` (static key).');
}
if (options.credential) {
const credential = options.credential;
const scope = options.scope ?? azure_js_1.AZURE_AI_SCOPE;
return async () => (0, OpenAIProvider_js_1.entraBearerToken)(ADAPTER, credential, scope);
}
// A string or a callback both pass straight through — `openai()` already
// owns the re-read-per-request contract for callbacks.
if (options.apiKey !== undefined)
return options.apiKey;
if (options._client) {
// The injected double replaces the WIRE, not the credential — and with no
// credential given there is nothing for a test to observe. Constructing a
// real DefaultAzureCredential underneath a double would reach for genuine
// cloud identity from inside a unit test, so the double runs keyless.
return undefined;
}
// NEITHER door named → the platform's own blessed zero-config pattern:
// inside a hosted Foundry container (injected FOUNDRY_PROJECT_ENDPOINT +
// per-agent managed identity), `new DefaultAzureCredential()` is what
// Microsoft's own FoundryChatClient does. Constructing the chain fetches
// NOTHING — tokens are minted per request by the callback below — but the
// peer-dep is loaded HERE, at factory time, so a missing package is refused
// where the consumer typed `foundry(...)` (the same stance `openai()` takes
// on its own SDK).
let sdk;
try {
sdk = (0, lazyRequire_js_1.lazyRequire)('@azure/identity');
}
catch {
throw new Error(`${ADAPTER}: keyless zero-config auth needs the \`@azure/identity\` package (an optional ` +
'peer this library never bundles).\n' +
' Fix: npm i @azure/identity — or pass `credential` or `apiKey` explicitly.');
}
if (typeof sdk.DefaultAzureCredential !== 'function') {
throw new Error(`${ADAPTER}: \`@azure/identity\` is installed but exports no \`DefaultAzureCredential\`.\n` +
' Fix: npm i @azure/identity@^4 — or pass `credential` or `apiKey` explicitly.');
}
const chain = new sdk.DefaultAzureCredential();
const scope = options.scope ?? azure_js_1.AZURE_AI_SCOPE;
return async () => (0, OpenAIProvider_js_1.entraBearerToken)(ADAPTER, chain, scope);
}
//# sourceMappingURL=FoundryProvider.js.map