diff --git a/.env.example b/.env.example index 29bbc80..c038567 100644 --- a/.env.example +++ b/.env.example @@ -3,15 +3,23 @@ ANTHROPIC_API_KEY= OPENAI_API_KEY= +# Default: beam:cloud through a local Ollama daemon (run `ollama signin` +# once for cloud models). Any tagged Ollama name works (e.g. qwen3:8b). # chat-latest follows ChatGPT Instant. gpt-* uses OpenAI; claude-* uses Anthropic. # Model access/availability must be verified with your provider. -# Blank or unsupported model prefixes fail at startup. -# Keys are optional for BYOK-only hosting; visitors enter both keys in the app. -LLM_MODEL=chat-latest +# Blank or unsupported model names fail at startup. +# With Ollama models no keys are needed; visitors may still add their own. +LLM_MODEL=beam:cloud +# OLLAMA_BASE_URL=http://localhost:11434/v1 +# OLLAMA_API_KEY= # Jev selects the renderer and visualization before each new user turn. +# Default: clef-flash in local Ollama (`ollama pull clef-flash`), no key needed. +# For hosted Jev set JEV_URL=https://api.typesafe.ai/v1/systemone, +# JEV_MODEL=jev-latest and TYPESAFE_API_KEY. +JEV_MODEL=clef-flash:latest +# JEV_URL=http://localhost:11434/v1/systemone TYPESAFE_API_KEY= -JEV_MODEL=jev-latest # Frontend settings: place these in apps/app/.env.local. # LANGGRAPH_DEPLOYMENT_URL defaults to http://localhost:8123. diff --git a/CLAUDE.md b/CLAUDE.md index d41dcb4..4bae3dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ Use the existing validated `Websandbox.connection.remote.sendPrompt({ text })` a ## Configuration and checks -The local default model is `chat-latest` (ChatGPT Instant). `claude-*` requires `ANTHROPIC_API_KEY`; `chat-latest` and `gpt-*` require `OPENAI_API_KEY`. Jev routing requires `TYPESAFE_API_KEY`. Do not invent native model APIs or silently change pinned model IDs. See `render.yaml` for its separate explicit deployment model setting. +The local default answer model is `beam:cloud` through Ollama's OpenAI-compatible API (`OLLAMA_BASE_URL`, default `http://localhost:11434/v1`); any tagged Ollama name works without a key. `claude-*` requires `ANTHROPIC_API_KEY`; `chat-latest` and `gpt-*` require `OPENAI_API_KEY`. Jev routing defaults to `clef-flash:latest` at Ollama's `/v1/systemone` (`JEV_URL`, `JEV_MODEL`); only hosted TypeSafe requires `TYPESAFE_API_KEY`. Visitor BYOK keys always use OpenAI `chat-latest` and hosted Jev, and the chat asks for them only when `GET /credentials/status` reports the server's own models unconfigured. Do not invent native model APIs or silently change pinned model IDs. See `render.yaml` for its separate explicit deployment model setting, which stays on OpenAI and hosted Jev. ```bash pnpm install diff --git a/Makefile b/Makefile index bc30108..3b443f7 100644 --- a/Makefile +++ b/Makefile @@ -8,8 +8,8 @@ install: ## Install all dependencies (Node + Python) setup: install ## Full setup: install deps and create .env from template @if [ ! -f apps/agent/.env ]; then \ - printf '%s\n' 'OPENAI_API_KEY=' 'TYPESAFE_API_KEY=' 'LLM_MODEL=chat-latest' 'JEV_MODEL=jev-latest' > apps/agent/.env; \ - echo "Created apps/agent/.env — set OPENAI_API_KEY and TYPESAFE_API_KEY"; \ + printf '%s\n' 'LLM_MODEL=beam:cloud' 'JEV_MODEL=clef-flash:latest' 'OPENAI_API_KEY=' 'TYPESAFE_API_KEY=' > apps/agent/.env; \ + echo "Created apps/agent/.env for Ollama — run 'ollama signin' and 'ollama pull clef-flash'"; \ else \ echo "apps/agent/.env already exists, skipping"; \ fi diff --git a/README.md b/README.md index 26671fa..fbc3e35 100644 --- a/README.md +++ b/README.md @@ -140,28 +140,27 @@ These starters run the agent; they are not prerecorded responses. Jev chooses th ## Get started -Prerequisites: Node.js 22+, pnpm 9+, Python 3.12+, and [uv](https://docs.astral.sh/uv/). +Prerequisites: Node.js 22+, pnpm 9+, Python 3.12+, [uv](https://docs.astral.sh/uv/), and [Ollama](https://ollama.com) signed in with `ollama signin` (needed for `:cloud` models). ```bash git clone https://github.com/CopilotKit/OpenIntelligentUI.git cd OpenIntelligentUI make setup -# Optional: set shared OPENAI_API_KEY and TYPESAFE_API_KEY in apps/agent/.env -# Or use API keys in the chat header after starting the app +ollama pull clef-flash:latest # local visualization router make dev ``` Open the [app](http://localhost:3000). The [agent health endpoint](http://localhost:8123/health) confirms the agent service is running. See [Getting started](docs/getting-started.md) for configuration and verification. -The default model is `chat-latest`, OpenAI’s documented alias for the latest ChatGPT Instant model, requiring `OPENAI_API_KEY`. Jev (`jev-latest`, requiring `TYPESAFE_API_KEY`) selects the renderer and visualization for each user turn. Basic tables use A2UI; charts, diagrams, calculators, and maps use Open Generative UI. Provider failures are surfaced instead of silently substituting another router or model. See [Visualization routing](docs/visualization-routing.md). +The default answer model is `beam:cloud`, served through your local Ollama daemon's OpenAI-compatible API. The visualization router defaults to `clef-flash:latest`, which answers Jev's System One API in the same daemon. Neither needs an API key. To use hosted providers instead, see [Getting started](docs/getting-started.md#configure-the-agent). The router selects the renderer and visualization for each user turn. Basic tables use A2UI; charts, diagrams, calculators, and maps use Open Generative UI. Provider failures are surfaced instead of silently substituting another router or model. See [Visualization routing](docs/visualization-routing.md). ## Use your own API keys -The chat asks for both keys before sending your first message or example prompt and keeps your draft while you set them up. Open **API keys** in the chat header, enter an OpenAI key and a Jev key, then choose **Test connection** and **Save keys**. The connection test makes a small request to each provider. Your keys use `chat-latest` for answers and `jev-latest` for visualization routing, with usage billed to your provider accounts. +With the default local models the chat never asks for keys. If the server's own models are not configured, the chat asks for both keys before sending your first message or example prompt and keeps your draft while you set them up. Open **API keys** in the chat header, enter an OpenAI key and a Jev key, then choose **Test connection** and **Save keys**. The connection test makes a small request to each provider. Your keys use `chat-latest` for answers and `jev-latest` for visualization routing, with usage billed to your provider accounts. Keys stay in browser memory until refresh or **Clear keys**; the app does not save them in browser storage. Requests send them through the application server to their respective providers. They are kept out of chat state and checkpoints, and hosted LangSmith tracing is disabled for requests using your keys. Only use this feature on a deployment whose operator you trust. Saving or clearing keys starts a new chat; **New chat** keeps your keys for the current session. -The agent can run without shared provider keys. Visitors must then supply both keys before chatting. Configured server credentials remain available to direct API clients that omit visitor keys; the chat interface requires visitors to add their own keys. +The chat checks `GET /credentials/status` on the agent. It asks visitors for keys only when the server's own answer model or router is unconfigured, for example a hosted `LLM_MODEL` without its key. Visitor keys always use OpenAI `chat-latest` and hosted Jev, never the server's Ollama models. ## How it works diff --git a/apps/agent/main.py b/apps/agent/main.py index 9fec803..eb56e71 100644 --- a/apps/agent/main.py +++ b/apps/agent/main.py @@ -20,6 +20,7 @@ CredentialsMiddleware, CredentialScopedAgent, current_credentials, + server_credentials_available, validate_credentials, ) from src.visualization_router import JevVisualizationMiddleware @@ -61,6 +62,12 @@ async def validate_provider_credentials(): return await validate_credentials(credentials) +@app.get("/credentials/status") +def provider_credentials_status(): + # Lets the chat skip the API-key prompt when the server's models are configured. + return {"required": not server_credentials_available()} + + @app.get("/health") def health(): return {"status": "ok"} diff --git a/apps/agent/src/credentials.py b/apps/agent/src/credentials.py index ea8cac9..b52d4fb 100644 --- a/apps/agent/src/credentials.py +++ b/apps/agent/src/credentials.py @@ -46,9 +46,12 @@ def parse_credentials(headers): def server_credentials_available(): - model = os.environ.get('LLM_MODEL', 'chat-latest').strip() - key = 'ANTHROPIC_API_KEY' if model.startswith('claude-') else 'OPENAI_API_KEY' - return bool(os.environ.get(key, '').strip() and os.environ.get('TYPESAFE_API_KEY', '').strip()) + """Whether the server's own models can answer without visitor keys.""" + from src.model import DEFAULT_MODEL, provider_key_name + from src.visualization_router import router_key_required + model_key = provider_key_name(os.environ.get('LLM_MODEL', DEFAULT_MODEL).strip()) + router_key = 'TYPESAFE_API_KEY' if router_key_required() else None + return all(os.environ.get(key, '').strip() for key in (model_key, router_key) if key) class CredentialsMiddleware: diff --git a/apps/agent/src/model.py b/apps/agent/src/model.py index 33725ab..eb516e2 100644 --- a/apps/agent/src/model.py +++ b/apps/agent/src/model.py @@ -1,5 +1,6 @@ """Chat model factory for the agent.""" +import json import os from contextlib import contextmanager @@ -10,7 +11,11 @@ from langchain_anthropic import ChatAnthropic from langchain_core.language_models.chat_models import BaseChatModel -DEFAULT_MODEL = "chat-latest" +# The server default runs through a local Ollama daemon (cloud models need +# `ollama signin`). Visitor BYOK keys always use OpenAI's chat-latest instead. +DEFAULT_MODEL = "beam:cloud" +BYOK_MODEL = "chat-latest" +DEFAULT_OLLAMA_URL = "http://localhost:11434/v1" # langchain-anthropic defaults max_tokens to 4096, which truncates # generateSandboxedUi tool args mid-stream (jsFunctions/jsExpressions never @@ -19,15 +24,44 @@ MAX_TOKENS = 64000 +def provider_key_name(model_name: str) -> str | None: + """Name the API key a model needs; None means an Ollama model or an invalid name.""" + if is_ollama_model(model_name): + # Checked first: Ollama tags such as gpt-oss:20b-cloud are not OpenAI models. + return None + if model_name == "chat-latest" or model_name.startswith("gpt-"): + return "OPENAI_API_KEY" + if model_name.startswith("claude-"): + return "ANTHROPIC_API_KEY" + return None + + +def is_ollama_model(model_name: str) -> bool: + # Ollama names carry an explicit tag (beam:cloud); this keeps typos + # like "gemini-example" failing at startup instead of at the first request. + name, _, tag = model_name.partition(":") + return bool(name and tag) and not any(c.isspace() for c in model_name) + + def build_model(*, allow_unconfigured: bool = False) -> BaseChatModel: model_name = os.environ.get("LLM_MODEL", DEFAULT_MODEL).strip() - is_openai = model_name == "chat-latest" or model_name.startswith("gpt-") - if not is_openai and not model_name.startswith("claude-"): + key_name = provider_key_name(model_name) + if key_name is None and not is_ollama_model(model_name): raise ValueError( - "LLM_MODEL must name a supported provider: claude-* (Anthropic) " - "or gpt-*/chat-latest (OpenAI). Unset LLM_MODEL to use the default." + "LLM_MODEL must name a supported provider: an Ollama model with a tag " + "(e.g. beam:cloud), claude-* (Anthropic) or gpt-*/chat-latest " + "(OpenAI). Unset LLM_MODEL to use the default." + ) + from langchain_openai import ChatOpenAI + + if key_name is None: + # Ollama serves an OpenAI-compatible API; a local daemon ignores the key. + return ChatOpenAI( + model=model_name, + base_url=os.environ.get("OLLAMA_BASE_URL", "").strip() or DEFAULT_OLLAMA_URL, + api_key=os.environ.get("OLLAMA_API_KEY", "").strip() or "ollama", + max_tokens=MAX_TOKENS, ) - key_name = "OPENAI_API_KEY" if is_openai else "ANTHROPIC_API_KEY" if not os.environ.get(key_name, "").strip(): if allow_unconfigured: return UnconfiguredModel() @@ -35,10 +69,8 @@ def build_model(*, allow_unconfigured: bool = False) -> BaseChatModel: f"{key_name} is required for the selected LLM_MODEL provider. " "Set it in the agent environment and restart the agent." ) - if is_openai: + if key_name == "OPENAI_API_KEY": # chat-latest follows ChatGPT Instant; explicit gpt-* pins also work. - from langchain_openai import ChatOpenAI - return ChatOpenAI(model=model_name) return ChatAnthropic( model=model_name, @@ -91,7 +123,7 @@ def request_model(fallback): # Clear that merge before any request so host Authorization cannot win. client._custom_headers = {} model = ChatOpenAI( - model=DEFAULT_MODEL, + model=BYOK_MODEL, api_key=credentials.openai, base_url=options["base_url"], openai_proxy="", @@ -163,17 +195,84 @@ async def _agenerate(self, messages, stop=None, run_manager=None, **kwargs): 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)) + for chunk in model.stream(messages, stop=stop, config=INNER_STREAM, **options): + for part in split_tool_call_chunk(as_chunk(chunk)): + yield ChatGenerationChunk(message=part) 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)) + async for chunk in model.astream(messages, stop=stop, config=INNER_STREAM, **options): + for part in split_tool_call_chunk(as_chunk(chunk)): + yield ChatGenerationChunk(message=part) + + +# The wrapped model's own stream events would reach AG-UI alongside this +# wrapper's, emitting every tool call twice; only the wrapper's are relayed. +INNER_STREAM = {"metadata": {"emit-messages": False, "emit-tool-calls": False}} def as_chunk(message): if isinstance(message, AIMessageChunk): return message return AIMessageChunk(**message.model_dump(exclude={"type"})) + + +def split_tool_call_chunk(chunk): + """Send a tool call's name before its arguments, as OpenAI streams them. + + Ollama delivers each tool call whole in one chunk, but AG-UI starts the + call on its named chunk and drops any arguments that chunk carries. + """ + calls = chunk.tool_call_chunks + if len(calls) != 1 or not calls[0].get("name") or not calls[0].get("args"): + return [chunk] + call = calls[0] + head = AIMessageChunk( + content=chunk.content, + id=chunk.id, + additional_kwargs=chunk.additional_kwargs, + response_metadata=chunk.response_metadata, + usage_metadata=chunk.usage_metadata, + tool_call_chunks=[{**call, "args": ""}], + ) + args = AIMessageChunk( + content="", + id=chunk.id, + tool_call_chunks=[ + {**call, "name": None, "id": None, "args": widget_args(call["name"], call["args"])} + ], + ) + return [head, args] + + +# CopilotKit's OpenGenerativeUIMiddleware creates the widget when it reads +# initialHeight and drops any css/html patches that arrive before it. +WIDGET_TOOL = "generateSandboxedUi" +DEFAULT_WIDGET_HEIGHT = 200 +WIDGET_ARG_ORDER = ("initialHeight", "placeholderMessages", "css", "html", "jsFunctions", "jsExpressions") + + +def widget_args(name, args): + """Put a whole widget call's arguments in the order the middleware needs. + + Ollama re-serializes arguments with its own key order, and smaller models + sometimes send jsExpressions as one string instead of a list. + """ + if name != WIDGET_TOOL: + return args + try: + parsed = json.loads(args) + except ValueError: + return args + if not isinstance(parsed, dict): + return args + if isinstance(parsed.get("jsExpressions"), str): + parsed["jsExpressions"] = [parsed["jsExpressions"]] + if not isinstance(parsed.get("initialHeight"), (int, float)): + # Without it the middleware never creates the widget; the renderer + # uses the same default and autosizes from there. + parsed["initialHeight"] = DEFAULT_WIDGET_HEIGHT + ordered = {key: parsed[key] for key in WIDGET_ARG_ORDER if key in parsed} + ordered.update((key, value) for key, value in parsed.items() if key not in ordered) + return json.dumps(ordered) diff --git a/apps/agent/src/visualization_router.py b/apps/agent/src/visualization_router.py index 928556b..db8a319 100644 --- a/apps/agent/src/visualization_router.py +++ b/apps/agent/src/visualization_router.py @@ -10,8 +10,16 @@ from langchain.agents.middleware import AgentMiddleware from langchain_core.messages import SystemMessage from pydantic import BaseModel, Field, ValidationError +from urllib.parse import urlparse +# Hosted Jev. Visitor BYOK keys are TypeSafe keys, so they always route here. JEV_URL = "https://api.typesafe.ai/v1/systemone" +# Server default: clef-flash answers the same System One API in local Ollama. +DEFAULT_ROUTER_URL = "http://localhost:11434/v1/systemone" +DEFAULT_ROUTER_MODEL = "clef-flash:latest" +HOSTED_TIMEOUT = 15 +# A local router loads its model on the first request, which takes 10-12s cold. +LOCAL_TIMEOUT = 60 VISUALIZATIONS = { "text": "Direct facts, prose, writing, code, conversation, or a clarification; no visual needed.", "table": "Basic table: exact values, records, simple side-by-side comparison; no custom interaction.", @@ -113,19 +121,47 @@ def routing_input(state): return turn, context +def server_router_url(): + return os.environ.get("JEV_URL", "").strip() or DEFAULT_ROUTER_URL + + +def router_key_required(): + """Only hosted TypeSafe needs a key; a self-hosted System One server does not.""" + host = urlparse(server_router_url()).hostname or "" + return host == "typesafe.ai" or host.endswith(".typesafe.ai") + + +def request_url(): + from src.credentials import current_credentials + + return JEV_URL if current_credentials.get() else server_router_url() + + +def request_timeout(): + from src.credentials import current_credentials + + if current_credentials.get() or router_key_required(): + return HOSTED_TIMEOUT + return LOCAL_TIMEOUT + + def request_options(context): from src.credentials import current_credentials credentials = current_credentials.get() key = credentials.jev if credentials else os.environ.get("TYPESAFE_API_KEY", "").strip() - if not key: + if not key and router_key_required(): raise ValueError( "TYPESAFE_API_KEY is required for Jev visualization routing. Configure it and restart the agent." ) + if credentials: + model = "jev-latest" + else: + model = os.environ.get("JEV_MODEL", "").strip() or DEFAULT_ROUTER_MODEL return { - "headers": {"Authorization": f"Bearer {key}"}, + "headers": {"Authorization": f"Bearer {key}"} if key else {}, "json": { - "model": "jev-latest" if credentials else os.environ.get("JEV_MODEL", "jev-latest"), + "model": model, "state": {"conversation": context}, "questions": { "visualization": { @@ -158,8 +194,8 @@ def provider_trust_env(): def route(context): try: - with httpx.Client(timeout=15, trust_env=provider_trust_env()) as client: - return parse_response(client.post(JEV_URL, **request_options(context))) + with httpx.Client(timeout=request_timeout(), trust_env=provider_trust_env()) as client: + return parse_response(client.post(request_url(), **request_options(context))) except httpx.HTTPError: raise ValueError( "Jev visualization routing is unavailable or timed out. Please retry." @@ -168,9 +204,9 @@ def route(context): async def aroute(context): try: - async with httpx.AsyncClient(timeout=15, trust_env=provider_trust_env()) as client: + async with httpx.AsyncClient(timeout=request_timeout(), trust_env=provider_trust_env()) as client: return parse_response( - await client.post(JEV_URL, **request_options(context)) + await client.post(request_url(), **request_options(context)) ) except httpx.HTTPError: raise ValueError( diff --git a/apps/agent/tests/test_credentials.py b/apps/agent/tests/test_credentials.py index 1217785..efec62b 100644 --- a/apps/agent/tests/test_credentials.py +++ b/apps/agent/tests/test_credentials.py @@ -89,12 +89,28 @@ def test_request_model_and_jev_override_without_environment_mutation(monkeypatch def test_startup_without_credentials_uses_explicit_unconfigured_model(monkeypatch): from src.model import build_model monkeypatch.delenv('OPENAI_API_KEY', raising=False) - monkeypatch.delenv('LLM_MODEL', raising=False) + monkeypatch.setenv('LLM_MODEL', 'chat-latest') model = build_model(allow_unconfigured=True) with pytest.raises(ValueError, match='credentials'): model.invoke('hello') +@pytest.mark.parametrize(('env', 'available'), [ + ({}, True), # Ollama defaults: beam answers, clef-flash routes, no keys + ({'LLM_MODEL': 'chat-latest'}, False), + ({'LLM_MODEL': 'chat-latest', 'OPENAI_API_KEY': 'k'}, True), + ({'JEV_URL': 'https://api.typesafe.ai/v1/systemone'}, False), + ({'JEV_URL': 'https://api.typesafe.ai/v1/systemone', 'TYPESAFE_API_KEY': 'k'}, True), +]) +def test_server_credentials_follow_the_configured_providers(monkeypatch, env, available): + from src.credentials import server_credentials_available + for name in ['LLM_MODEL', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'JEV_URL', 'TYPESAFE_API_KEY']: + monkeypatch.delenv(name, raising=False) + for name, value in env.items(): + monkeypatch.setenv(name, value) + assert server_credentials_available() is available + + def test_thread_namespace_separates_credentials_and_fallback(): from src.credentials import Credentials, current_credentials, private_thread_id ids = [] @@ -164,10 +180,22 @@ def test_main_starts_without_server_credentials(monkeypatch): import sys environment = {k: v for k, v in os.environ.items() if k not in {'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'TYPESAFE_API_KEY', 'LLM_MODEL'}} environment['PYTHON_DOTENV_DISABLED'] = '1' + # An OpenAI model without its key: the Ollama default needs no credentials. + environment['LLM_MODEL'] = 'chat-latest' result = subprocess.run([sys.executable, '-c', "from fastapi.testclient import TestClient; from main import app; c=TestClient(app); assert c.get('/health').status_code == 200; r=c.post('/', json={}); assert r.status_code == 401; assert r.json()['code'] == 'credentials_required'"], env=environment, capture_output=True, text=True) assert result.returncode == 0, result.stderr +@pytest.mark.parametrize(('model', 'required'), [('chat-latest', True), ('beam:cloud', False)]) +def test_credentials_status_tells_the_chat_whether_keys_are_needed(model, required): + import subprocess + import sys + environment = {k: v for k, v in os.environ.items() if k not in {'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'TYPESAFE_API_KEY', 'JEV_URL'}} + environment.update(PYTHON_DOTENV_DISABLED='1', LLM_MODEL=model) + result = subprocess.run([sys.executable, '-c', f"from fastapi.testclient import TestClient; from main import app; assert TestClient(app).get('/credentials/status').json() == {{'required': {required}}}"], env=environment, capture_output=True, text=True) + assert result.returncode == 0, result.stderr + + @pytest.mark.parametrize(('statuses', 'code'), [([200, 200], None), ([200, 403], 'jev_invalid'), ([429, 200], 'provider_unavailable')]) def test_validation_endpoint_results(monkeypatch, statuses, code): from fastapi import FastAPI diff --git a/apps/agent/tests/test_model.py b/apps/agent/tests/test_model.py index b9fc29f..50b6335 100644 --- a/apps/agent/tests/test_model.py +++ b/apps/agent/tests/test_model.py @@ -1,5 +1,6 @@ """Model defaults and provider-specific validation.""" +import json import os import pytest @@ -13,11 +14,30 @@ from src.model import build_model -def test_default_model_is_chatgpt_instant(monkeypatch): +def test_default_model_is_beam_through_local_ollama(monkeypatch): monkeypatch.delenv("LLM_MODEL", raising=False) + monkeypatch.delenv("OLLAMA_BASE_URL", raising=False) model = build_model() assert isinstance(model, ChatOpenAI) - assert model.model_name == "chat-latest" + assert model.model_name == "beam:cloud" + assert model.openai_api_base == "http://localhost:11434/v1" + assert model.max_tokens >= 32000 + + +def test_ollama_models_need_no_provider_key(monkeypatch): + monkeypatch.setenv("LLM_MODEL", "qwen3:8b") + monkeypatch.setenv("OLLAMA_BASE_URL", "http://gpu-box:11434/v1") + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + model = build_model() + assert model.model_name == "qwen3:8b" + assert model.openai_api_base == "http://gpu-box:11434/v1" + + +def test_ollama_gpt_oss_tag_is_not_routed_to_openai(monkeypatch): + from src.model import provider_key_name + + assert provider_key_name("gpt-oss:20b-cloud") is None + assert provider_key_name("gpt-5.4-2026-03-05") == "OPENAI_API_KEY" def test_llm_model_env_override(monkeypatch: pytest.MonkeyPatch): @@ -84,3 +104,93 @@ 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 whole_tool_call_chunk(): + from langchain_core.messages import AIMessageChunk + + return AIMessageChunk( + content="", + id="run-1", + tool_call_chunks=[ + {"name": "generateSandboxedUi", "args": '{"initialHeight": 300, "html": "
Hi
"}', "id": "call_1", "index": 0} + ], + ) + + +def test_whole_ollama_tool_call_streams_name_before_arguments(): + from src.model import split_tool_call_chunk + + head, args = split_tool_call_chunk(whole_tool_call_chunk()) + assert head.tool_call_chunks[0]["name"] == "generateSandboxedUi" + assert head.tool_call_chunks[0]["args"] == "" + assert args.tool_call_chunks[0]["name"] is None + assert json.loads(args.tool_call_chunks[0]["args"]) == {"initialHeight": 300, "html": "Hi
"} + assert (head + args).tool_calls == whole_tool_call_chunk().tool_calls + + +def test_streamed_tool_calls_reach_ag_ui_once(): + import asyncio + from langchain_core.language_models.chat_models import BaseChatModel + from langchain_core.outputs import ChatGenerationChunk + from src.model import CredentialScopedModel + + class WholeToolCallModel(BaseChatModel): + @property + def _llm_type(self): + return "whole-tool-call" + + def _generate(self, *args, **kwargs): + raise NotImplementedError + + def _stream(self, *args, **kwargs): + yield ChatGenerationChunk(message=whole_tool_call_chunk()) + + async def relayed_chunks(): + model = CredentialScopedModel(fallback=WholeToolCallModel()) + return [ + event["data"]["chunk"] + async for event in model.astream_events("Build it", version="v2") + if event["event"] == "on_chat_model_stream" + # AG-UI drops events carrying these flags. + and event["metadata"].get("emit-tool-calls", True) + and event["data"]["chunk"].tool_call_chunks + ] + + chunks = asyncio.run(relayed_chunks()) + assert [c.tool_call_chunks[0]["name"] for c in chunks] == ["generateSandboxedUi", None] + + +def test_widget_arguments_reach_copilotkit_in_streaming_order(): + from src.model import widget_args + + whole = json.dumps({ + "css": "p{}", + "html": "", + "initialHeight": 520, + "jsExpressions": "draw()", + "jsFunctions": "function draw(){}", + "placeholderMessages": ["Drawing"], + }) + args = json.loads(widget_args("generateSandboxedUi", whole)) + # initialHeight creates the widget; html sent before it would be dropped. + assert list(args) == ["initialHeight", "placeholderMessages", "css", "html", "jsFunctions", "jsExpressions"] + assert args["jsExpressions"] == ["draw()"] + + +def test_widget_without_initial_height_still_gets_created(): + from src.model import widget_args + + args = json.loads(widget_args("generateSandboxedUi", '{"jsFunctions":"f()"}')) + assert list(args) == ["initialHeight", "jsFunctions"] + assert args["initialHeight"] == 200 + + +@pytest.mark.parametrize( + ("name", "args"), + [("query_data", '{"query":"revenue"}'), ("generateSandboxedUi", '{"html":"cut off')],
+)
+def test_other_tools_and_unparseable_widget_arguments_pass_through(name, args):
+ from src.model import widget_args
+
+ assert widget_args(name, args) == args
diff --git a/apps/agent/tests/test_visualization_router.py b/apps/agent/tests/test_visualization_router.py
index af1a5d0..239a07d 100644
--- a/apps/agent/tests/test_visualization_router.py
+++ b/apps/agent/tests/test_visualization_router.py
@@ -107,14 +107,38 @@ def test_route_filters_renderers_preserving_data_tools(choice, allowed):
assert "coverage limits" in result.system_message.content
-def test_missing_key_is_actionable(monkeypatch):
+def test_missing_key_is_actionable_for_hosted_jev(monkeypatch):
from src.visualization_router import request_options
+ monkeypatch.setenv("JEV_URL", "https://api.typesafe.ai/v1/systemone")
monkeypatch.delenv("TYPESAFE_API_KEY", raising=False)
with pytest.raises(ValueError, match="TYPESAFE_API_KEY"):
request_options([])
+def test_default_routes_through_clef_flash_in_local_ollama(monkeypatch):
+ from src.visualization_router import request_options, request_url
+
+ for name in ("JEV_URL", "JEV_MODEL", "TYPESAFE_API_KEY"):
+ monkeypatch.delenv(name, raising=False)
+ options = request_options([])
+ assert request_url() == "http://localhost:11434/v1/systemone"
+ assert options["json"]["model"] == "clef-flash:latest"
+ assert options["headers"] == {}
+
+
+def test_visitor_jev_keys_always_use_hosted_jev(monkeypatch):
+ from src.credentials import Credentials, current_credentials
+ from src.visualization_router import JEV_URL, request_url
+
+ monkeypatch.delenv("JEV_URL", raising=False)
+ token = current_credentials.set(Credentials("visitor-openai", "visitor-jev"))
+ try:
+ assert request_url() == JEV_URL
+ finally:
+ current_credentials.reset(token)
+
+
def test_provider_errors_do_not_expose_response_body():
import httpx
from src.visualization_router import parse_response
@@ -139,6 +163,17 @@ def timeout(*args, **kwargs):
assert "private-test-value" not in str(error.value)
+@pytest.mark.parametrize(
+ "jev_url, expected",
+ [("", 60), ("http://localhost:11434/v1/systemone", 60), ("https://api.typesafe.ai/v1/systemone", 15)],
+)
+def test_local_router_gets_time_to_load_its_model(monkeypatch, jev_url, expected):
+ import src.visualization_router as router
+
+ monkeypatch.setenv("JEV_URL", jev_url)
+ assert router.request_timeout() == expected
+
+
def test_context_excludes_system_and_tool_content_and_is_bounded():
from langchain_core.messages import ToolMessage
diff --git a/apps/app/src/app/api/provider-keys/route.test.ts b/apps/app/src/app/api/provider-keys/route.test.ts
index 0bb9288..4783a4f 100644
--- a/apps/app/src/app/api/provider-keys/route.test.ts
+++ b/apps/app/src/app/api/provider-keys/route.test.ts
@@ -1,9 +1,27 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from "vitest";
-import { POST } from "./route";
+import { GET, POST } from "./route";
const headers = { "x-openai-api-key": "openai-test-secret", "x-jev-api-key": "jev-test-secret" };
afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); });
+describe("provider key requirement", () => {
+ it("relays only an explicit 'not required' from the agent", async () => {
+ vi.stubEnv("LANGGRAPH_DEPLOYMENT_URL", "agent:8123");
+ const fetchMock = vi.fn().mockResolvedValue(Response.json({ required: false }));
+ vi.stubGlobal("fetch", fetchMock);
+ const response = await GET();
+ expect(fetchMock).toHaveBeenCalledWith("http://agent:8123/credentials/status", expect.objectContaining({ redirect: "error" }));
+ expect(await response.json()).toEqual({ required: false });
+ });
+ it.each([
+ ["an agent error", () => Promise.resolve(Response.json({ required: false }, { status: 500 }))],
+ ["an unreachable agent", () => Promise.reject(new Error("down"))],
+ ["a malformed reply", () => Promise.resolve(Response.json({ required: "no" }))],
+ ])("keeps keys required on %s", async (_, reply) => {
+ vi.stubGlobal("fetch", vi.fn().mockImplementation(reply));
+ expect(await (await GET()).json()).toEqual({ required: true });
+ });
+});
describe("provider key validation proxy", () => {
it("requires both keys before contacting the backend", async () => {
const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock);
diff --git a/apps/app/src/app/api/provider-keys/route.ts b/apps/app/src/app/api/provider-keys/route.ts
index 4839b5b..0195e13 100644
--- a/apps/app/src/app/api/provider-keys/route.ts
+++ b/apps/app/src/app/api/provider-keys/route.ts
@@ -11,6 +11,24 @@ const reply = (body: object, status: number) => Response.json(body, {
status, headers: { "Cache-Control": "no-store" },
});
+const agentUrl = (path: string) =>
+ `${normalizeLanggraphUrl(process.env.LANGGRAPH_DEPLOYMENT_URL).replace(/\/$/, "")}${path}`;
+
+/** Whether visitors must bring keys. Fails closed: any doubt keeps the key prompt. */
+export async function GET() {
+ try {
+ const response = await fetch(agentUrl("/credentials/status"), {
+ cache: "no-store",
+ redirect: "error",
+ signal: AbortSignal.timeout(10_000),
+ });
+ const data = await response.json();
+ return reply({ required: !(response.ok && data?.required === false) }, 200);
+ } catch {
+ return reply({ required: true }, 200);
+ }
+}
+
export async function POST(request: Request) {
let credentials;
try {
@@ -20,9 +38,7 @@ export async function POST(request: Request) {
return reply({ error: PROVIDER_KEY_INPUT_ERROR }, 400);
}
try {
- const base = normalizeLanggraphUrl(process.env.LANGGRAPH_DEPLOYMENT_URL);
- const url = `${base.replace(/\/$/, "")}/credentials/validate`;
- const response = await fetch(url, {
+ const response = await fetch(agentUrl("/credentials/validate"), {
method: "POST",
headers: credentials,
cache: "no-store",
diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx
index 3d82645..65c9218 100644
--- a/apps/app/src/app/page.tsx
+++ b/apps/app/src/app/page.tsx
@@ -18,7 +18,7 @@ export default function HomePage() {
useGenerativeUIExamples();
useExampleSuggestions();
const { agent } = useAgent();
- const { newChat, hasKeys, keysOpen, setKeysOpen } = useProviderKeys();
+ const { newChat, hasKeys, canChat, keysOpen, setKeysOpen } = useProviderKeys();
const { copilotkit } = useCopilotKit();
const [error, setError] = useState