diff --git a/docs/04-model-providers.md b/docs/04-model-providers.md index a3f1731..f2881ee 100644 --- a/docs/04-model-providers.md +++ b/docs/04-model-providers.md @@ -50,8 +50,12 @@ user already has. No key. If it is not installed, the row offers a link, not an | Agent settings model picker | Dropdown in panel, backed by `ModelProviderCatalog` | | Settings → Models | **Built.** Provider rows with live connect/disconnect, Ollama detection, and **Add a custom provider** | -**Not built yet:** native Anthropic/OpenAI/Google adapters (compatible mode reaches all three), -usage accounting, and the live two-vendor smoke that closes M2. +**Built since:** the three native adapters — `buildChatModel` in `agent-langgraph/src/models/build.ts` +hands Anthropic to `ChatAnthropic`, Google to `ChatGoogleGenerativeAI`, and OpenAI plus every +compatible endpoint to `ChatOpenAI` at its own base URL. `tests/models-build.test.ts` asserts which +client each selection gets, because a wrong one still answers. Usage accounting is built too. + +**Not built yet:** the live two-vendor smoke that closes M2, which needs a second vendor key. **Ollama container routing:** the app probes Ollama on the host (`localhost:11434`) and routes engine calls through `hostGatewayAddress()` from the runtime driver diff --git a/docs/12-roadmap.md b/docs/12-roadmap.md index 7f89759..a30bcc2 100644 --- a/docs/12-roadmap.md +++ b/docs/12-roadmap.md @@ -151,8 +151,8 @@ An end-to-end conversation through the server needs Intelligence credentials: `runtimeCapabilities()` takes all four `INTELLIGENCE_*` variables or none, and none selects `LocalIntelligence`, which is a spike that throws (ADR-0007). -**Still open:** native OpenAI/Google adapters (compatible mode covers them for now) and a second -live vendor. Usage accounting is done — the agent sums `usage_metadata` across a turn and emits +**Still open:** a second live vendor. The three native adapters are built and each is covered by a +test asserting which client a selection gets (`agent-langgraph/tests/models-build.test.ts`). Usage accounting is done — the agent sums `usage_metadata` across a turn and emits `CUSTOM`/`xbot.usage` before `RUN_FINISHED`, and Settings → Usage shows it per agent. --- diff --git a/engine/agent-langgraph/src/index.ts b/engine/agent-langgraph/src/index.ts index c23f3f1..b9b6b0a 100644 --- a/engine/agent-langgraph/src/index.ts +++ b/engine/agent-langgraph/src/index.ts @@ -1,15 +1,12 @@ import type { BaseEvent, RunAgentInput } from "@ag-ui/core"; import { EventEncoder } from "@ag-ui/encoder"; -import { ChatAnthropic } from "@langchain/anthropic"; import { type AIMessage, ToolMessage } from "@langchain/core/messages"; -import { ChatGoogleGenerativeAI } from "@langchain/google-genai"; import { END, MessagesAnnotation, START, StateGraph, } from "@langchain/langgraph"; -import { ChatOpenAI } from "@langchain/openai"; import { serve } from "bun"; import { hasManagedAgentToken } from "../../shared/agent-authorisation"; import { @@ -18,7 +15,8 @@ import { } from "../../shared/model-selection"; import { toLangChainMessages } from "./history"; import { readReasoningEffort } from "./model-options"; -import { explainModelError, resolveModel } from "./models/registry"; +import { buildChatModel } from "./models/build"; +import { explainModelError } from "./models/registry"; import { streamRun } from "./stream"; /** @@ -256,76 +254,18 @@ function toBoundTools(input: RunAgentInput) { } /** - * The chat model, from whichever provider *this run* is for. + * The chat model for this run. * - * Every one of these binds tools the same way and streams the same way, which is exactly why the - * rest of this file does not know which one it got — and why per-agent selection fits here at all. - * - * This is the seam ADR-0002 asks for. The provider used to be a module constant; now it is an - * argument, and `models/registry.ts` decides what it resolves to. The function is otherwise the - * upstream one, which is deliberate: a rewritten file is a permanent merge conflict. + * The body lives in `models/build.ts` so it can be tested without booting this server — the + * provider-to-client hop is the one that has failed silently before, and a wrong client still + * answers. This keeps the environment-derived deployment defaults here, where they are read. */ function buildModel(selection: ModelSelection | undefined) { - const { model: resolved, problem } = resolveModel({ + return buildChatModel({ selection, fallback: DEPLOYMENT_DEFAULT, keys: DEPLOYMENT_KEYS, - }); - /* - * Thrown, not exited. A missing key at *boot* is the deployer's problem and still exits below; - * a missing key for one agent mid-flight is that agent's problem, and taking the process down - * would end every other agent's conversation over it. `streamRun` turns this into a RUN_ERROR - * carrying the sentence the registry wrote, which is the one the person needs to read. - */ - if (!resolved) { - throw new Error(problem?.message ?? "No model is selected for this agent."); - } - - if (resolved.providerId === "anthropic") { - return new ChatAnthropic({ - model: resolved.model, - apiKey: resolved.apiKey, - streaming: true, - ...(resolved.baseURL ? { anthropicApiUrl: resolved.baseURL } : {}), - }); - } - if (resolved.providerId === "google") { - return new ChatGoogleGenerativeAI({ - model: resolved.model, - apiKey: resolved.apiKey, - streaming: true, - ...(resolved.baseURL ? { baseUrl: resolved.baseURL } : {}), - }); - } - /* - * OpenAI and `openai-compatible` are the same client with a different address. - * - * That is the whole trick, and why docs/04 calls the compatible adapter the highest-leverage - * piece of the router: xAI, Ollama, OpenRouter, Groq, Together, DeepSeek, LM Studio and any - * corporate gateway all speak this API, so they cost a base URL rather than an adapter each. - */ - return new ChatOpenAI({ - model: resolved.model, - apiKey: resolved.apiKey, - streaming: true, - ...(resolved.baseURL - ? { configuration: { baseURL: resolved.baseURL } } - : {}), - ...(resolved.useResponsesApi ? { useResponsesApi: true } : {}), - /* - * `reasoning.effort`, not the `reasoningEffort` convenience field: the integration deprecated - * the latter in favour of merging it into this object, and one of them is the one that survives. - * - * Gated on the *resolved* provider, not the configured one. The startup check below only knows - * what the environment chose, so without this an agent switched to Anthropic from a dropdown - * would be sent an OpenAI-only setting — the "configuration that goes nowhere" failure the - * effort check exists to prevent, arriving by the new route. - */ - ...(REASONING_EFFORT && - resolved.providerId === "openai" && - resolved.useResponsesApi - ? { reasoning: { effort: REASONING_EFFORT } } - : {}), + reasoningEffort: REASONING_EFFORT, }); } diff --git a/engine/agent-langgraph/src/models/build.ts b/engine/agent-langgraph/src/models/build.ts new file mode 100644 index 0000000..23be3fd --- /dev/null +++ b/engine/agent-langgraph/src/models/build.ts @@ -0,0 +1,104 @@ +import { ChatAnthropic } from "@langchain/anthropic"; +import { ChatGoogleGenerativeAI } from "@langchain/google-genai"; +import { ChatOpenAI } from "@langchain/openai"; +import type { ModelSelection } from "../../../shared/model-selection"; +import type { ReasoningEffort } from "../model-options"; +import { resolveModel } from "./registry"; + +/** + * Which client a resolved selection is answered by. + * + * Lifted out of `index.ts` so it can be tested without booting the server. It is worth testing on + * its own: this hop has failed silently three times (docs/12) — parsed but dropped, sent under the + * wrong key, stored but never read back — and every one of those looked identical from outside, + * because the wrong client still answers. A test that asserts on the client and the address it was + * given is the only thing that tells those apart without a live vendor. + */ +export interface BuildRequest { + /** What this agent chose. Absent means it never chose. */ + selection: ModelSelection | undefined; + /** The workspace default, which is what an agent that never chose inherits. */ + fallback: ModelSelection | undefined; + /** Deployment-wide keys by provider id, from the vault. Never from the environment. */ + keys: Record; + /** OpenAI's setting, and only ever applied to OpenAI on the Responses API. */ + reasoningEffort?: ReasoningEffort; +} + +/** + * The chat model, from whichever provider *this run* is for. + * + * Every one of these binds tools the same way and streams the same way, which is exactly why the + * rest of the runtime does not know which one it got — and why per-agent selection fits here at all. + * + * This is the seam ADR-0002 asks for. The provider used to be a module constant; now it is an + * argument, and `models/registry.ts` decides what it resolves to. + */ +export function buildChatModel({ + selection, + fallback, + keys, + reasoningEffort, +}: BuildRequest) { + const { model: resolved, problem } = resolveModel({ + selection, + fallback, + keys, + }); + /* + * Thrown, not exited. A missing key at *boot* is the deployer's problem and still exits at + * startup; a missing key for one agent mid-flight is that agent's problem, and taking the process + * down would end every other agent's conversation over it. `streamRun` turns this into a + * RUN_ERROR carrying the sentence the registry wrote, which is the one the person needs to read. + */ + if (!resolved) { + throw new Error(problem?.message ?? "No model is selected for this agent."); + } + + if (resolved.providerId === "anthropic") { + return new ChatAnthropic({ + model: resolved.model, + apiKey: resolved.apiKey, + streaming: true, + ...(resolved.baseURL ? { anthropicApiUrl: resolved.baseURL } : {}), + }); + } + if (resolved.providerId === "google") { + return new ChatGoogleGenerativeAI({ + model: resolved.model, + apiKey: resolved.apiKey, + streaming: true, + ...(resolved.baseURL ? { baseUrl: resolved.baseURL } : {}), + }); + } + /* + * OpenAI and `openai-compatible` are the same client with a different address. + * + * That is the whole trick, and why docs/04 calls the compatible adapter the highest-leverage + * piece of the router: xAI, Ollama, OpenRouter, Groq, Together, DeepSeek, LM Studio and any + * corporate gateway all speak this API, so they cost a base URL rather than an adapter each. + */ + return new ChatOpenAI({ + model: resolved.model, + apiKey: resolved.apiKey, + streaming: true, + ...(resolved.baseURL + ? { configuration: { baseURL: resolved.baseURL } } + : {}), + ...(resolved.useResponsesApi ? { useResponsesApi: true } : {}), + /* + * `reasoning.effort`, not the `reasoningEffort` convenience field: the integration deprecated + * the latter in favour of merging it into this object, and one of them is the one that survives. + * + * Gated on the *resolved* provider, not the configured one. The startup check only knows what + * the environment chose, so without this an agent switched to Anthropic from a dropdown would + * be sent an OpenAI-only setting — the "configuration that goes nowhere" failure the effort + * check exists to prevent, arriving by the new route. + */ + ...(reasoningEffort && + resolved.providerId === "openai" && + resolved.useResponsesApi + ? { reasoning: { effort: reasoningEffort } } + : {}), + }); +} diff --git a/engine/agent-langgraph/tests/models-build.test.ts b/engine/agent-langgraph/tests/models-build.test.ts new file mode 100644 index 0000000..d5b1757 --- /dev/null +++ b/engine/agent-langgraph/tests/models-build.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test"; +import { ChatAnthropic } from "@langchain/anthropic"; +import { ChatGoogleGenerativeAI } from "@langchain/google-genai"; +import { ChatOpenAI } from "@langchain/openai"; +import { buildChatModel } from "../src/models/build"; + +/** + * Which client answers a selection, and at which address. + * + * The registry's own tests prove a selection resolves; these prove the resolution is *acted on*. + * docs/12 records three failures on this path — parsed but dropped, sent under the wrong key, + * stored but never read back — and all three looked healthy from outside, because a run answered + * either way. Only the client and the address it was handed tell them apart without a live vendor. + */ +const keys = { openai: "sk-o", anthropic: "sk-a", google: "sk-g" }; + +describe("the client a selection is answered by", () => { + test("anthropic gets Anthropic's client, on the model that was asked for", () => { + const model = buildChatModel({ + selection: { providerId: "anthropic", model: "claude-sonnet-4-5" }, + fallback: undefined, + keys, + }); + expect(model).toBeInstanceOf(ChatAnthropic); + expect((model as ChatAnthropic).model).toBe("claude-sonnet-4-5"); + }); + + test("google gets Google's client", () => { + const model = buildChatModel({ + selection: { providerId: "google", model: "gemini-2.5-flash" }, + fallback: undefined, + keys, + }); + expect(model).toBeInstanceOf(ChatGoogleGenerativeAI); + expect((model as ChatGoogleGenerativeAI).model).toBe("gemini-2.5-flash"); + }); + + test("an openai-compatible endpoint is OpenAI's client at somebody else's address", () => { + // The base URL is the entire difference, so a build that dropped it would send a local Ollama + // run to OpenAI — with a key that works, which is how this goes unnoticed. + const model = buildChatModel({ + selection: { + providerId: "openai-compatible", + model: "llama3.1", + baseURL: "http://host.docker.internal:11434/v1", + }, + fallback: undefined, + keys, + }) as ChatOpenAI; + expect(model).toBeInstanceOf(ChatOpenAI); + expect(model.model).toBe("llama3.1"); + expect(model.clientConfig.baseURL).toBe( + "http://host.docker.internal:11434/v1", + ); + }); + + test("an agent that never chose inherits the workspace default", () => { + const model = buildChatModel({ + selection: undefined, + fallback: { providerId: "anthropic", model: "claude-sonnet-4-5" }, + keys, + }); + expect(model).toBeInstanceOf(ChatAnthropic); + }); + + test("no selection and no default is the agent's error, not the process's", () => { + // Thrown rather than exited: taking the process down would end every other agent's + // conversation over one agent's missing model. + expect(() => + buildChatModel({ selection: undefined, fallback: undefined, keys }), + ).toThrow(); + }); + + test("a missing key names the provider rather than failing at the vendor", () => { + expect(() => + buildChatModel({ + selection: { providerId: "google", model: "gemini-2.5-flash" }, + fallback: undefined, + keys: { google: undefined }, + }), + ).toThrow(/google|Google/); + }); + + test("reasoning effort reaches OpenAI on the Responses API", () => { + const model = buildChatModel({ + selection: { providerId: "openai", model: "gpt-5.6" }, + fallback: undefined, + keys, + reasoningEffort: "high", + }) as ChatOpenAI; + expect(model.reasoning).toEqual({ effort: "high" }); + }); + + test("and never reaches a provider whose API has no such setting", () => { + // The failure this prevents: an agent switched to Anthropic from a dropdown, still carrying + // OpenAI's setting, configured in a way that goes nowhere. + const model = buildChatModel({ + selection: { providerId: "anthropic", model: "claude-sonnet-4-5" }, + fallback: undefined, + keys, + reasoningEffort: "high", + }); + expect(model).toBeInstanceOf(ChatAnthropic); + expect("reasoning" in model).toBe(false); + }); +}); diff --git a/engine/biome.json b/engine/biome.json index 6b05e7e..7f0ef77 100644 --- a/engine/biome.json +++ b/engine/biome.json @@ -4,6 +4,7 @@ "includes": [ "**", "!**/dist", + "!**/.impeccable", "!.", "!app/src/components/ui", "!app/src/lib/generated/application-config.ts",