Skip to content
Merged
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
8 changes: 6 additions & 2 deletions docs/04-model-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/12-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down
76 changes: 8 additions & 68 deletions engine/agent-langgraph/src/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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";

/**
Expand Down Expand Up @@ -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,
});
}

Expand Down
104 changes: 104 additions & 0 deletions engine/agent-langgraph/src/models/build.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>;
/** 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 } }
: {}),
});
}
106 changes: 106 additions & 0 deletions engine/agent-langgraph/tests/models-build.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
1 change: 1 addition & 0 deletions engine/biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"includes": [
"**",
"!**/dist",
"!**/.impeccable",
"!.",
"!app/src/components/ui",
"!app/src/lib/generated/application-config.ts",
Expand Down
Loading