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
14 changes: 14 additions & 0 deletions apps/agent/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim

WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy

COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project

COPY main.py ./
COPY src ./src
COPY skills ./skills

EXPOSE 8123
CMD ["uv", "run", "--no-sync", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8123"]
34 changes: 26 additions & 8 deletions apps/agent/src/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from langchain.agents.middleware import AgentMiddleware
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
from langchain_core.messages import AIMessageChunk
from langchain_core.runnables import RunnableBinding
from pydantic import Field
from langchain_anthropic import ChatAnthropic
from langchain_core.language_models.chat_models import BaseChatModel
Expand Down Expand Up @@ -146,31 +147,48 @@ def _target(self, kwargs):
tool_options = options.pop("_credential_tool_options", {})
if tools is not None:
model = model.bind_tools(tools, **tool_options)
# Call the provider's private methods so only this wrapper's run reports
# events. A public nested call inherits the graph's callbacks and streams
# every token and tool-call delta to AG-UI a second time.
if isinstance(model, RunnableBinding):
options = {**model.kwargs, **options}
model = model.bound
return model, options

def _generate(self, messages, stop=None, run_manager=None, **kwargs):
with provider_error_boundary():
model, options = self._target(kwargs)
message = model.invoke(messages, stop=stop, **options)
return ChatResult(generations=[ChatGeneration(message=message)])
return model._generate(messages, stop=stop, **options)

async def _agenerate(self, messages, stop=None, run_manager=None, **kwargs):
with provider_error_boundary():
model, options = self._target(kwargs)
message = await model.ainvoke(messages, stop=stop, **options)
return ChatResult(generations=[ChatGeneration(message=message)])
return await model._agenerate(messages, stop=stop, **options)

def _stream(self, messages, stop=None, run_manager=None, **kwargs):
with provider_error_boundary():
model, options = self._target(kwargs)
for chunk in model.stream(messages, stop=stop, **options):
yield ChatGenerationChunk(message=as_chunk(chunk))
if not streams(model):
yield as_generation_chunk(model._generate(messages, stop=stop, **options))
return
yield from model._stream(messages, stop=stop, **options)

async def _astream(self, messages, stop=None, run_manager=None, **kwargs):
with provider_error_boundary():
model, options = self._target(kwargs)
async for chunk in model.astream(messages, stop=stop, **options):
yield ChatGenerationChunk(message=as_chunk(chunk))
if not streams(model):
yield as_generation_chunk(await model._agenerate(messages, stop=stop, **options))
return
async for chunk in model._astream(messages, stop=stop, **options):
yield chunk


def streams(model):
return type(model)._stream is not BaseChatModel._stream or type(model)._astream is not BaseChatModel._astream


def as_generation_chunk(result):
return ChatGenerationChunk(message=as_chunk(result.generations[0].message))


def as_chunk(message):
Expand Down
29 changes: 29 additions & 0 deletions apps/agent/tests/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,32 @@ def test_missing_provider_key_has_actionable_error(monkeypatch, model, key, valu
monkeypatch.setenv(key, value)
with pytest.raises(ValueError, match=key):
build_model()


def test_credential_scoped_model_streams_each_chunk_once():
import asyncio
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.messages import AIMessage
from langchain_core.runnables import RunnableLambda
from src.model import CredentialScopedModel

class ToolFake(GenericFakeChatModel):
def bind_tools(self, tools, **kwargs):
return self.bind(tools=tools)

inner = ToolFake(messages=iter([AIMessage(content="a b c")]))
# Bound tools wrap the provider in a RunnableBinding, as in the real agent.
wrapped = CredentialScopedModel(fallback=inner).bind_tools([{"name": "t"}])

# Like a LangGraph node: the parent run's callbacks are inherited by calls inside it.
async def node(_):
return await wrapped.ainvoke("hi")

async def stream_events():
return [
e async for e in RunnableLambda(node).astream_events("x", version="v2")
if e["event"] == "on_chat_model_stream"
]

tokens = [e["data"]["chunk"].content for e in asyncio.run(stream_events())]
assert "".join(tokens) == "a b c"
7 changes: 7 additions & 0 deletions apps/app/src/components/chat/provider-keys.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,11 @@ describe("memory-only provider keys", () => {
fireEvent.click(screen.getByText("Cancel"));
expect(options?.signal?.aborted).toBe(true);
});
it("treats server-held keys as configured when NEXT_PUBLIC_SERVER_KEYS is true", () => {
vi.stubEnv("NEXT_PUBLIC_SERVER_KEYS", "true");
render(<ProviderKeysProvider><Harness /></ProviderKeysProvider>);
// Gate passes, but no browser headers are sent: the agent uses its own .env keys.
expect(screen.getByTestId("active")).toBeEmptyDOMElement();
vi.unstubAllEnvs();
});
});
3 changes: 2 additions & 1 deletion apps/app/src/components/chat/provider-keys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ export function ProviderKeysProvider({ children }: { children: ReactNode }) {
return (
<ProviderKeysContext.Provider value={{
headers: keys ? { "x-openai-api-key": keys.openai, "x-jev-api-key": keys.jev } : undefined,
hasKeys: keys !== null,
// Self-hosted builds can rely on the agent's own .env keys instead.
hasKeys: keys !== null || process.env.NEXT_PUBLIC_SERVER_KEYS === "true",
session,
save: (value) => { setKeys(value); setKeysOpen(false); setSession((value) => value + 1); },
clear: () => { setKeys(null); newChat(); },
Expand Down
30 changes: 30 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
services:
agent:
build: apps/agent
env_file: apps/agent/.env
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8123/health')"]
interval: 30s

mcp:
build:
context: .
dockerfile: docker/Dockerfile.mcp
ports:
- "127.0.0.1:3100:3100"
restart: unless-stopped

app:
build:
context: .
dockerfile: docker/Dockerfile.app
args:
NEXT_PUBLIC_SERVER_KEYS: "true"
environment:
LANGGRAPH_DEPLOYMENT_URL: http://agent:8123
MCP_SERVER_URL: http://mcp:3100/mcp
ports:
- "127.0.0.1:${APP_PORT:-3000}:3000"
depends_on: [agent, mcp]
restart: unless-stopped
3 changes: 3 additions & 0 deletions docker/Dockerfile.app
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ COPY --from=deps /app/packages/design-system/node_modules ./packages/design-syst
# Copy source code
COPY . .

# Inlined at build time; "true" skips the browser key prompt.
ARG NEXT_PUBLIC_SERVER_KEYS

# Enable pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate

Expand Down
19 changes: 19 additions & 0 deletions docker/Dockerfile.mcp
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# The MCP depends on @repo/design-system, so build from the workspace root.
FROM node:22-alpine AS builder
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@9 --activate
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
COPY apps/mcp/package.json ./apps/mcp/
COPY packages/design-system/package.json ./packages/design-system/
RUN pnpm install --frozen-lockfile --filter open-intelligent-ui-mcp...
COPY packages/design-system ./packages/design-system
COPY apps/mcp ./apps/mcp
RUN pnpm exec turbo run build --filter=open-intelligent-ui-mcp

FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app ./
WORKDIR /app/apps/mcp
USER node
EXPOSE 3100
CMD ["node", "dist/index.js"]
11 changes: 11 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,20 @@ Optional settings belong in `apps/app/.env.local` or the frontend process enviro
| `RATE_LIMIT_ENABLED` | `false` | Per-IP runtime rate limit |
| `RATE_LIMIT_WINDOW_MS` | `60000` | Rate-limit window |
| `RATE_LIMIT_MAX` | `40` | Requests per window |
| `NEXT_PUBLIC_SERVER_KEYS` | Unset | Build-time; `true` skips the browser key prompt when the agent has its own keys |

The root [.env.example](../.env.example) documents both services' settings; placing variables only in a root `.env` is not the setup described here.

## Run with Docker Compose

To self-host the app, agent, and MCP server with the agent's own keys, fill in `apps/agent/.env` (see above) and run:

```bash
docker compose up -d
```

The app listens on `http://localhost:3000` (set `APP_PORT` to change it) and the MCP server on `http://localhost:3100/mcp`; both bind to `127.0.0.1`. The compose build sets `NEXT_PUBLIC_SERVER_KEYS=true`, so chat uses the agent's `.env` keys without asking for browser keys. After editing `apps/agent/.env`, run `docker compose up -d agent` to apply it.

## Start and verify

```bash
Expand Down