diff --git a/.env.example b/.env.example index dcecf20..351ad4e 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,8 @@ OPENAI_API_KEY= # chat-latest follows ChatGPT Instant. gpt-* uses OpenAI; claude-* uses Anthropic. # Model access/availability must be verified with your provider. -# Blank or unsupported prefixes and missing selected-provider keys fail at startup. +# 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 # Jev selects the renderer and visualization before each new user turn. diff --git a/README.md b/README.md index be4a3cb..aeaed4c 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,8 @@ Prerequisites: Node.js 22+, pnpm 9+, Python 3.12+, and [uv](https://docs.astral. git clone https://github.com/CopilotKit/OpenIntelligentUI.git cd OpenIntelligentUI make setup -# Set OPENAI_API_KEY and TYPESAFE_API_KEY in apps/agent/.env +# 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 make dev ``` @@ -151,6 +152,14 @@ Open the [app](http://localhost:3000). The [agent health endpoint](http://localh 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). +## Use your own API keys + +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 when visitors have not supplied their own. + ## How it works The Python Deep Agent uses a task-first system prompt and focused skills. CopilotKit carries the agent stream to the Next.js app. Native components handle supported structured tasks; `generateSandboxedUi` streams custom HTML, CSS, and JavaScript into an isolated iframe. diff --git a/apps/agent/main.py b/apps/agent/main.py index 2347b2e..9fec803 100644 --- a/apps/agent/main.py +++ b/apps/agent/main.py @@ -8,13 +8,20 @@ from dotenv import load_dotenv from fastapi import FastAPI -from copilotkit import CopilotKitMiddleware, LangGraphAGUIAgent +from fastapi.responses import JSONResponse +from copilotkit import CopilotKitMiddleware from ag_ui_langgraph import add_langgraph_fastapi_endpoint from deepagents import create_deep_agent from src.anthropic_compat import ConsecutiveSystemMessagesMiddleware from src.bounded_memory_saver import BoundedMemorySaver -from src.model import build_model +from src.model import build_model, RequestModelMiddleware, CredentialScopedModel +from src.credentials import ( + CredentialsMiddleware, + CredentialScopedAgent, + current_credentials, + validate_credentials, +) from src.visualization_router import JevVisualizationMiddleware from src.skill_backend import SKILL_SOURCES, build_agent_backend from src.query import query_data @@ -27,9 +34,10 @@ load_dotenv() agent = create_deep_agent( - model=build_model(), + model=CredentialScopedModel(fallback=build_model(allow_unconfigured=True)), tools=[query_data, get_trip_stop_images, plan_visualization, *todo_tools, generate_form], middleware=[ + RequestModelMiddleware(), CopilotKitMiddleware(), JevVisualizationMiddleware(), ConsecutiveSystemMessagesMiddleware(), @@ -42,6 +50,15 @@ ) app = FastAPI() +app.add_middleware(CredentialsMiddleware) + + +@app.post("/credentials/validate") +async def validate_provider_credentials(): + credentials = current_credentials.get() + if credentials is None: + return JSONResponse({"ok": False, "code": "invalid_keys"}, status_code=400) + return await validate_credentials(credentials) @app.get("/health") @@ -51,7 +68,7 @@ def health(): add_langgraph_fastapi_endpoint( app=app, - agent=LangGraphAGUIAgent( + agent=CredentialScopedAgent( name="sample_agent", description="Open Generative UI by CopilotKit — answers you can interact with", graph=agent, diff --git a/apps/agent/src/credentials.py b/apps/agent/src/credentials.py new file mode 100644 index 0000000..ea8cac9 --- /dev/null +++ b/apps/agent/src/credentials.py @@ -0,0 +1,129 @@ +"""Ephemeral provider credentials, scoped to the complete ASGI stream lifetime.""" + +import asyncio +from contextvars import ContextVar +from dataclasses import dataclass, field +import hashlib +import hmac +import json +import os + +import httpx +from copilotkit import LangGraphAGUIAgent +from langsmith import tracing_context +from starlette.responses import JSONResponse + + +@dataclass(frozen=True) +class Credentials: + openai: str = field(repr=False) + jev: str = field(repr=False) + + +current_credentials: ContextVar[Credentials | None] = ContextVar('provider_credentials', default=None) +SENSITIVE_HEADERS = {b'x-openai-api-key', b'x-jev-api-key'} + + +def parse_credentials(headers): + values = {} + for name, value in headers: + name = name.lower() + if name in SENSITIVE_HEADERS: + if name in values: + raise ValueError('invalid_keys') + # Reject controls before trimming: HTTP whitespace is spaces only. + if any(c < 32 or c > 126 for c in value): + raise ValueError('invalid_keys') + value = value.strip(b' ') + if not value or len(value) > 4096 or any(c <= 32 for c in value): + raise ValueError('invalid_keys') + values[name] = value.decode('ascii') + if not values: + return None + if len(values) != 2: + raise ValueError('invalid_keys') + return Credentials(values[b'x-openai-api-key'], values[b'x-jev-api-key']) + + +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()) + + +class CredentialsMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope['type'] != 'http': + return await self.app(scope, receive, send) + try: + credentials = parse_credentials(scope.get('headers', [])) + except ValueError: + return await JSONResponse({'ok': False, 'code': 'invalid_keys'}, status_code=400)(scope, receive, send) + if scope['path'] == '/' and not credentials and not server_credentials_available(): + return await JSONResponse({'ok': False, 'code': 'credentials_required'}, status_code=401)(scope, receive, send) + clean_scope = {**scope, 'headers': [(k, v) for k, v in scope.get('headers', []) if k.lower() not in SENSITIVE_HEADERS]} + token = current_credentials.set(credentials) + try: + # BYOK provider calls and graph runs must never enter hosted traces. + with tracing_context(enabled=False if credentials else None): + await self.app(clean_scope, receive, send) + finally: + current_credentials.reset(token) + + +def private_thread_id(thread_id): + credentials = current_credentials.get() + if credentials: + key = hashlib.sha256(json.dumps([credentials.openai, credentials.jev]).encode()).digest() + namespace = 'byok' + else: + key = b'open-intelligent-ui-server-threads-v1' + namespace = 'server' + return namespace + ':' + hmac.new(key, thread_id.encode(), hashlib.sha256).hexdigest() + + +class CredentialScopedAgent(LangGraphAGUIAgent): + async def run(self, input): + original = input.thread_id + scoped = input.model_copy(update={'thread_id': private_thread_id(original)}) + async for event in super().run(scoped): + # The wire protocol retains the client ID; only graph storage sees the private ID. + if hasattr(event, 'thread_id'): + event = event.model_copy(update={'thread_id': original}) + yield event + + +async def validate_credentials(credentials): + """Exercise both APIs; never forward their response bodies or exceptions.""" + from src.visualization_router import JEV_URL, request_options + + async def check(provider, url, options): + try: + async with httpx.AsyncClient(timeout=12, follow_redirects=False, trust_env=False) as client: + response = await client.post(url, **options) + if response.is_success: + return None + if response.status_code in {400, 401, 403, 404}: + return provider + '_invalid' + except httpx.HTTPError: + pass + return 'provider_unavailable' + + token = current_credentials.set(credentials) + try: + results = await asyncio.gather( + check('openai', 'https://api.openai.com/v1/chat/completions', { + 'headers': {'Authorization': f'Bearer {credentials.openai}'}, + 'json': {'model': 'chat-latest', 'messages': [{'role': 'user', 'content': 'Reply OK.'}], 'max_completion_tokens': 8}, + }), + check('jev', JEV_URL, request_options([{'role': 'human', 'content': 'Hello'}])), + ) + finally: + current_credentials.reset(token) + error = next((result for result in results if result), None) + if error: + return JSONResponse({'ok': False, 'code': error}, status_code=503 if error == 'provider_unavailable' else 401) + return JSONResponse({'ok': True}) diff --git a/apps/agent/src/model.py b/apps/agent/src/model.py index 32163eb..33725ab 100644 --- a/apps/agent/src/model.py +++ b/apps/agent/src/model.py @@ -1,7 +1,12 @@ """Chat model factory for the agent.""" import os +from contextlib import contextmanager +from langchain.agents.middleware import AgentMiddleware +from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult +from langchain_core.messages import AIMessageChunk +from pydantic import Field from langchain_anthropic import ChatAnthropic from langchain_core.language_models.chat_models import BaseChatModel @@ -14,7 +19,7 @@ MAX_TOKENS = 64000 -def build_model() -> BaseChatModel: +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-"): @@ -24,6 +29,8 @@ def build_model() -> BaseChatModel: ) 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() raise ValueError( f"{key_name} is required for the selected LLM_MODEL provider. " "Set it in the agent environment and restart the agent." @@ -37,3 +44,136 @@ def build_model() -> BaseChatModel: model=model_name, max_tokens=MAX_TOKENS, ) + + +class UnconfiguredModel(BaseChatModel): + """Compile a BYOK-only graph without constructing a provider with fake credentials.""" + + @property + def _llm_type(self): + return "unconfigured" + + def _generate(self, *args, **kwargs): + raise ValueError("Provider credentials are required. Add OpenAI and Jev keys.") + + def bind_tools(self, tools, **kwargs): + return self + + +def request_model(fallback): + from src.credentials import current_credentials + credentials = current_credentials.get() + if credentials is None: + return fallback + from langchain_openai import ChatOpenAI + from openai import AsyncOpenAI, OpenAI, DefaultAsyncHttpxClient, DefaultHttpxClient + + # SDK None defaults inherit server organization/project settings. Explicit + # clients with empty scopes and isolated transports prevent that inheritance, + # including server HTTP(S)_PROXY and OPENAI_PROXY settings. + options = { + "api_key": credentials.openai, + "base_url": "https://api.openai.com/v1", + "organization": "", + "project": "", + } + sync_client = OpenAI(**options, http_client=DefaultHttpxClient(trust_env=False)) + async_client = AsyncOpenAI( + **options, http_client=DefaultAsyncHttpxClient(trust_env=False) + ) + # After construction, None omits these headers without rereading env vars. + for client in (sync_client, async_client): + client.organization = None + client.project = None + client.admin_api_key = None + client.webhook_secret = None + # The SDK merges OPENAI_CUSTOM_HEADERS even with default_headers={}. + # Clear that merge before any request so host Authorization cannot win. + client._custom_headers = {} + model = ChatOpenAI( + model=DEFAULT_MODEL, + api_key=credentials.openai, + base_url=options["base_url"], + openai_proxy="", + root_client=sync_client, + client=sync_client.chat.completions, + root_async_client=async_client, + async_client=async_client.chat.completions, + ) + # LangChain's metadata field separately inherits organization environment + # values even when prebuilt SDK clients are supplied. Clear the inert field. + model.openai_organization = None + return model + + +@contextmanager +def provider_error_boundary(): + from src.credentials import current_credentials + try: + yield + except Exception: + if current_credentials.get() is None: + raise + raise ValueError("OpenAI request failed. Check your key and model access, or retry.") from None + + +class RequestModelMiddleware(AgentMiddleware): + def wrap_model_call(self, request, handler): + with provider_error_boundary(): + return handler(request.override(model=request_model(request.model))) + + async def awrap_model_call(self, request, handler): + with provider_error_boundary(): + return await handler(request.override(model=request_model(request.model))) + + +class CredentialScopedModel(BaseChatModel): + """Route DeepAgents' internal summary/subagent model calls through BYOK too.""" + + fallback: BaseChatModel = Field(exclude=True) + + @property + def _llm_type(self): + return "credential-scoped" + + def bind_tools(self, tools, **kwargs): + return self.bind(_credential_tools=tools, _credential_tool_options=kwargs) + + def _target(self, kwargs): + model = request_model(self.fallback) + options = dict(kwargs) + tools = options.pop("_credential_tools", None) + tool_options = options.pop("_credential_tool_options", {}) + if tools is not None: + model = model.bind_tools(tools, **tool_options) + 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)]) + + 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)]) + + 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)) + + 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)) + + +def as_chunk(message): + if isinstance(message, AIMessageChunk): + return message + return AIMessageChunk(**message.model_dump(exclude={"type"})) diff --git a/apps/agent/src/visualization_router.py b/apps/agent/src/visualization_router.py index b204b30..928556b 100644 --- a/apps/agent/src/visualization_router.py +++ b/apps/agent/src/visualization_router.py @@ -114,7 +114,10 @@ def routing_input(state): def request_options(context): - key = os.environ.get("TYPESAFE_API_KEY", "").strip() + 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: raise ValueError( "TYPESAFE_API_KEY is required for Jev visualization routing. Configure it and restart the agent." @@ -122,7 +125,7 @@ def request_options(context): return { "headers": {"Authorization": f"Bearer {key}"}, "json": { - "model": os.environ.get("JEV_MODEL", "jev-latest"), + "model": "jev-latest" if credentials else os.environ.get("JEV_MODEL", "jev-latest"), "state": {"conversation": context}, "questions": { "visualization": { @@ -148,9 +151,14 @@ def parse_response(response): ) from exc +def provider_trust_env(): + from src.credentials import current_credentials + return current_credentials.get() is None + + def route(context): try: - with httpx.Client(timeout=15) as client: + with httpx.Client(timeout=15, trust_env=provider_trust_env()) as client: return parse_response(client.post(JEV_URL, **request_options(context))) except httpx.HTTPError: raise ValueError( @@ -160,7 +168,7 @@ def route(context): async def aroute(context): try: - async with httpx.AsyncClient(timeout=15) as client: + async with httpx.AsyncClient(timeout=15, trust_env=provider_trust_env()) as client: return parse_response( await client.post(JEV_URL, **request_options(context)) ) diff --git a/apps/agent/tests/test_credentials.py b/apps/agent/tests/test_credentials.py new file mode 100644 index 0000000..1217785 --- /dev/null +++ b/apps/agent/tests/test_credentials.py @@ -0,0 +1,349 @@ +import asyncio +import json +import os + +import httpx +import pytest + + +def test_credentials_feature_exists(): + from src import model + assert hasattr(model, 'RequestModelMiddleware') + + +def test_headers_validate_without_disclosing_values(): + from src.credentials import parse_credentials + assert parse_credentials([]) is None + keys = parse_credentials([(b'x-openai-api-key', b' openai-one '), (b'x-jev-api-key', b' jev-one ')]) + assert keys.openai == 'openai-one' + assert keys.jev == 'jev-one' + assert 'openai-one' not in repr(keys) + for value in [b'', b'foo bar', b'bad\nkey', b'\x80', b'a' * 4097]: + with pytest.raises(ValueError, match='invalid_keys'): + parse_credentials([(b'x-openai-api-key', value), (b'x-jev-api-key', b'jev')]) + with pytest.raises(ValueError): + parse_credentials([(b'x-openai-api-key', b'only')]) + + +def test_concurrent_streams_isolate_keys_and_reset_on_cancellation(): + from src.credentials import CredentialsMiddleware, current_credentials + async def exercise(): + entered = asyncio.Event() + count = 0 + observed = [] + async def inner(scope, receive, send): + nonlocal count + assert not any(k.startswith(b'x-openai') or k.startswith(b'x-jev') for k, _ in scope['headers']) + count += 1 + if count == 2: + entered.set() + await entered.wait() + key = current_credentials.get().openai + observed.append(key) + await asyncio.sleep(0) + assert current_credentials.get().openai == key + if current_credentials.get().openai == 'cancel': + raise asyncio.CancelledError + await send({'type': 'http.response.start', 'status': 200, 'headers': []}) + await send({'type': 'http.response.body', 'body': b'ok'}) + app = CredentialsMiddleware(inner) + async def run(key): + scope = {'type': 'http', 'path': '/', 'headers': [(b'x-openai-api-key', key.encode()), (b'x-jev-api-key', b'jev')]} + async def receive(): + return {'type': 'http.disconnect'} + async def send(message): + pass + try: + await app(scope, receive, send) + except asyncio.CancelledError: + pass + assert current_credentials.get() is None + await asyncio.gather(run('one'), run('cancel')) + assert sorted(observed) == ['cancel', 'one'] + asyncio.run(exercise()) + + +def test_request_model_and_jev_override_without_environment_mutation(monkeypatch): + from src.credentials import Credentials, current_credentials + from src.model import request_model + from src.visualization_router import request_options + monkeypatch.setenv('OPENAI_API_KEY', 'server-openai') + monkeypatch.setenv('TYPESAFE_API_KEY', 'server-jev') + monkeypatch.setenv('JEV_MODEL', 'server-model') + token = current_credentials.set(Credentials('visitor-openai', 'visitor-jev')) + try: + model = request_model(None) + assert model.model_name == 'chat-latest' + assert model.openai_api_key.get_secret_value() == 'visitor-openai' + assert request_options([])['headers']['Authorization'] == 'Bearer visitor-jev' + assert request_options([])['json']['model'] == 'jev-latest' + assert os.environ['OPENAI_API_KEY'] == 'server-openai' + finally: + current_credentials.reset(token) + assert request_options([])['headers']['Authorization'] == 'Bearer server-jev' + assert request_options([])['json']['model'] == 'server-model' + sentinel = object() + assert request_model(sentinel) is sentinel + + +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) + model = build_model(allow_unconfigured=True) + with pytest.raises(ValueError, match='credentials'): + model.invoke('hello') + + +def test_thread_namespace_separates_credentials_and_fallback(): + from src.credentials import Credentials, current_credentials, private_thread_id + ids = [] + for creds in [None, Credentials('a', 'b'), Credentials('c', 'd')]: + token = current_credentials.set(creds) + try: + first = private_thread_id('same-thread') + assert first == private_thread_id('same-thread') + ids.append(first) + finally: + current_credentials.reset(token) + assert len(set(ids)) == 3 + assert all('same-thread' not in value for value in ids) + + +def test_validation_checks_both_providers_and_sanitizes_failure(monkeypatch): + from src.credentials import Credentials, validate_credentials + requests = [] + async def post(self, url, **kwargs): + requests.append((url, kwargs)) + return httpx.Response(401, json={'error': 'SECRET visitor-openai'}) + monkeypatch.setattr(httpx.AsyncClient, 'post', post) + result = asyncio.run(validate_credentials(Credentials('visitor-openai', 'visitor-jev'))) + assert result.status_code == 401 + assert json.loads(result.body)['code'] == 'openai_invalid' + assert 'SECRET' not in result.body.decode() + assert len(requests) == 2 + + +def test_agent_stream_keeps_credentials_out_of_checkpoints_and_separates_threads(): + from ag_ui.core import RunAgentInput + from langchain_core.messages import AIMessage + from langgraph.graph import StateGraph, MessagesState, START, END + from src.bounded_memory_saver import BoundedMemorySaver + from src.credentials import CredentialScopedAgent, Credentials, current_credentials + + seen = [] + async def answer(state): + seen.append((current_credentials.get().openai, len(state['messages']))) + return {'messages': [AIMessage(content='OK')]} + builder = StateGraph(MessagesState) + builder.add_node('answer', answer) + builder.add_edge(START, 'answer') + builder.add_edge('answer', END) + saver = BoundedMemorySaver() + adapter = CredentialScopedAgent(name='test', graph=builder.compile(checkpointer=saver)) + async def exercise(): + for index, key in enumerate(['visitor-first', 'visitor-second']): + token = current_credentials.set(Credentials(key, 'visitor-jev')) + try: + input = RunAgentInput(thread_id='shared', run_id=f'run-{index}', messages=[{'id': f'message-{index}', 'role': 'user', 'content': 'Hello'}], state={}, tools=[], context=[], forwarded_props={}) + events = [event async for event in adapter.clone().run(input)] + assert events + assert all(event.thread_id == 'shared' for event in events if hasattr(event, 'thread_id')) + finally: + current_credentials.reset(token) + asyncio.run(exercise()) + assert seen == [('visitor-first', 1), ('visitor-second', 1)] + assert len(saver.storage) == 2 + persisted = repr((saver.storage, saver.writes, saver.blobs)) + # Message and run identifiers intentionally contain no credentials either. + assert all(secret not in persisted for secret in ['visitor-first', 'visitor-second', 'visitor-jev']) + + +def test_main_starts_without_server_credentials(monkeypatch): + 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', 'LLM_MODEL'}} + environment['PYTHON_DOTENV_DISABLED'] = '1' + 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(('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 + from fastapi.testclient import TestClient + from src.credentials import CredentialsMiddleware, current_credentials, validate_credentials + app = FastAPI() + app.add_middleware(CredentialsMiddleware) + @app.post('/credentials/validate') + async def validate(): + return await validate_credentials(current_credentials.get()) + async def post(self, url, **kwargs): + return httpx.Response(statuses[0 if 'openai' in url else 1]) + monkeypatch.setattr(httpx.AsyncClient, 'post', post) + response = TestClient(app).post('/credentials/validate', headers={'x-openai-api-key': 'openai', 'x-jev-api-key': 'jev'}) + assert response.json() == ({'ok': False, 'code': code} if code else {'ok': True}) + + +def test_actual_stream_disconnect_retains_then_releases_context(): + from starlette.responses import StreamingResponse + from src.credentials import CredentialsMiddleware, current_credentials + async def exercise(): + first_chunk = asyncio.Event() + finalized = asyncio.Event() + observed = [] + async def chunks(): + try: + observed.append(current_credentials.get().openai) + yield b'first' + await asyncio.Event().wait() + finally: + observed.append(current_credentials.get().openai) + finalized.set() + async def inner(scope, receive, send): + await StreamingResponse(chunks())(scope, receive, send) + async def receive(): + await first_chunk.wait() + return {'type': 'http.disconnect'} + async def send(message): + if message['type'] == 'http.response.body': + first_chunk.set() + scope = {'type': 'http', 'path': '/', 'asgi': {'spec_version': '2.0'}, 'headers': [(b'x-openai-api-key', b'visitor'), (b'x-jev-api-key', b'jev')]} + await CredentialsMiddleware(inner)(scope, receive, send) + assert finalized.is_set() + assert observed == ['visitor', 'visitor'] + assert current_credentials.get() is None + asyncio.run(exercise()) + + +def test_internal_model_calls_also_use_request_credentials(monkeypatch): + from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel + from langchain_core.messages import AIMessage + import src.model as models + assert hasattr(models, 'CredentialScopedModel') + fallback = FakeMessagesListChatModel(responses=[AIMessage(content='server')]) + visitor = FakeMessagesListChatModel(responses=[AIMessage(content='visitor')]) + monkeypatch.setattr(models, 'request_model', lambda ignored: visitor) + wrapped = models.CredentialScopedModel(fallback=fallback) + assert wrapped.invoke('summary').content == 'visitor' + assert asyncio.run(wrapped.ainvoke('summary')).content == 'visitor' + + +def test_internal_provider_errors_do_not_expose_upstream_content(monkeypatch): + from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel + from langchain_core.messages import AIMessage + from src.credentials import Credentials, current_credentials + import src.model as models + class FailingModel(FakeMessagesListChatModel): + def _generate(self, *args, **kwargs): + raise ValueError('upstream includes visitor-secret') + failing = FailingModel(responses=[AIMessage(content='unused')]) + monkeypatch.setattr(models, 'request_model', lambda ignored: failing) + token = current_credentials.set(Credentials('visitor-secret', 'jev')) + try: + with pytest.raises(ValueError) as error: + models.CredentialScopedModel(fallback=failing).invoke('summary') + assert 'visitor-secret' not in str(error.value) + finally: + current_credentials.reset(token) + + +def test_internal_streaming_errors_are_sanitized(monkeypatch): + from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel + from langchain_core.messages import AIMessage, AIMessageChunk + from langchain_core.outputs import ChatGenerationChunk + from src.credentials import Credentials, current_credentials + import src.model as models + class FailingStream(FakeMessagesListChatModel): + def _stream(self, *args, **kwargs): + yield ChatGenerationChunk(message=AIMessageChunk(content='first')) + raise ValueError('upstream includes visitor-secret') + failing = FailingStream(responses=[AIMessage(content='unused')]) + monkeypatch.setattr(models, 'request_model', lambda ignored: failing) + async def exercise(): + token = current_credentials.set(Credentials('visitor-secret', 'jev')) + try: + wrapped = models.CredentialScopedModel(fallback=failing) + with pytest.raises(ValueError) as error: + [chunk async for chunk in wrapped.astream('summary')] + assert 'visitor-secret' not in str(error.value) + finally: + current_credentials.reset(token) + asyncio.run(exercise()) + + +def test_byok_clients_ignore_server_openai_environment(monkeypatch): + from src.credentials import Credentials, current_credentials + from src.model import request_model + poison = { + 'OPENAI_CUSTOM_HEADERS': 'Authorization: Bearer host-key\nOpenAI-Organization: host-org\nX-Host-Secret: host-secret', + 'OPENAI_ADMIN_KEY': 'host-admin', + 'OPENAI_WEBHOOK_SECRET': 'host-webhook', + 'OPENAI_ORG_ID': 'server-org', + 'OPENAI_ORGANIZATION': 'server-organization', + 'OPENAI_PROJECT_ID': 'server-project', + 'OPENAI_PROXY': 'http://127.0.0.1:9999', + 'OPENAI_BASE_URL': 'http://127.0.0.1:9998', + 'HTTPS_PROXY': 'http://127.0.0.1:9997', + } + for name, value in poison.items(): + monkeypatch.setenv(name, value) + token = current_credentials.set(Credentials('visitor-key', 'visitor-jev')) + try: + model = request_model(None) + for client in [model.root_client, model.root_async_client]: + assert client.organization is None + assert client.project is None + assert str(client.base_url) == 'https://api.openai.com/v1/' + assert client.api_key == 'visitor-key' + assert not client._client.trust_env + assert 'X-Host-Secret' not in client.default_headers + assert client.admin_api_key is None + assert client.webhook_secret is None + sent = [] + def send(client, request, **kwargs): + sent.append(request) + return httpx.Response(200, request=request, json={ + 'id': 'test', 'object': 'chat.completion', 'created': 0, + 'model': 'chat-latest', 'choices': [], + }) + async def asend(client, request, **kwargs): + return send(client, request, **kwargs) + monkeypatch.setattr(httpx.Client, 'send', send) + monkeypatch.setattr(httpx.AsyncClient, 'send', asend) + model.root_client.chat.completions.create(model='chat-latest', messages=[]) + asyncio.run(model.root_async_client.chat.completions.create(model='chat-latest', messages=[])) + assert len(sent) == 2 + for request in sent: + assert request.headers['Authorization'] == 'Bearer visitor-key' + assert 'OpenAI-Organization' not in request.headers + assert 'OpenAI-Project' not in request.headers + assert 'X-Host-Secret' not in request.headers + assert not model.openai_proxy + assert all(os.environ[name] == value for name, value in poison.items()) + finally: + current_credentials.reset(token) + + +def test_byok_validation_and_jev_ignore_proxy_environment(monkeypatch): + from src.credentials import Credentials, current_credentials, validate_credentials + from src.visualization_router import route, aroute + observed = [] + def post(self, url, **kwargs): + observed.append(self.trust_env) + return httpx.Response(503) + async def apost(self, url, **kwargs): + return post(self, url, **kwargs) + monkeypatch.setattr(httpx.Client, 'post', post) + monkeypatch.setattr(httpx.AsyncClient, 'post', apost) + creds = Credentials('visitor', 'jev') + asyncio.run(validate_credentials(creds)) + token = current_credentials.set(creds) + try: + with pytest.raises(ValueError): + route([]) + with pytest.raises(ValueError): + asyncio.run(aroute([])) + finally: + current_credentials.reset(token) + assert observed == [False, False, False, False] diff --git a/apps/agent/tests/test_skill_backend.py b/apps/agent/tests/test_skill_backend.py index d3c9287..1e7bb60 100644 --- a/apps/agent/tests/test_skill_backend.py +++ b/apps/agent/tests/test_skill_backend.py @@ -85,7 +85,7 @@ class ToolCapableFakeModel(FakeMessagesListChatModel): def bind_tools(self, tools, **kwargs): return self - monkeypatch.setattr(src.model, 'build_model', lambda: ToolCapableFakeModel( + monkeypatch.setattr(src.model, 'build_model', lambda **kwargs: ToolCapableFakeModel( responses=[AIMessage(content='Ready')], )) # Import the real graph configuration, replacing only the network model. diff --git a/apps/app/src/app/api/copilotkit/route.ts b/apps/app/src/app/api/copilotkit/route.ts index 9845968..ee3bfc3 100644 --- a/apps/app/src/app/api/copilotkit/route.ts +++ b/apps/app/src/app/api/copilotkit/route.ts @@ -4,6 +4,7 @@ import { copilotRuntimeNextJSAppRouterEndpoint, } from "@copilotkit/runtime"; import { NextRequest } from "next/server"; +import { readProviderHeaders, PROVIDER_KEY_INPUT_ERROR } from "@/lib/provider-keys"; import { buildRuntimeOptions } from "@/lib/copilotkit-runtime-options"; // Simple in-memory sliding-window rate limiter (per IP) @@ -35,17 +36,25 @@ if (RATE_LIMIT_ENABLED) { }, 300_000); } -const runtimeOptions = buildRuntimeOptions({ - langgraphUrl: process.env.LANGGRAPH_DEPLOYMENT_URL, - mcpServerUrl: process.env.MCP_SERVER_URL, -}); - export const POST = async (req: NextRequest) => { const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; if (isRateLimited(ip)) { return new Response("Too many requests", { status: 429 }); } + let providerHeaders; + try { + providerHeaders = readProviderHeaders(req.headers); + } catch { + return Response.json({ error: PROVIDER_KEY_INPUT_ERROR }, { status: 400, headers: { "Cache-Control": "no-store" } }); + } + // Construct the agent per request so one visitor's keys cannot reach another. + const runtimeOptions = buildRuntimeOptions({ + langgraphUrl: process.env.LANGGRAPH_DEPLOYMENT_URL, + mcpServerUrl: process.env.MCP_SERVER_URL, + providerHeaders, + }); + const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({ endpoint: "/api/copilotkit", serviceAdapter: new ExperimentalEmptyAdapter(), diff --git a/apps/app/src/app/api/provider-keys/route.test.ts b/apps/app/src/app/api/provider-keys/route.test.ts new file mode 100644 index 0000000..0bb9288 --- /dev/null +++ b/apps/app/src/app/api/provider-keys/route.test.ts @@ -0,0 +1,41 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from "vitest"; +import { 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 validation proxy", () => { + it("requires both keys before contacting the backend", async () => { + const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); + const response = await POST(new Request("http://localhost/api/provider-keys", { method: "POST" })); + expect(response.status).toBe(400); expect(fetchMock).not.toHaveBeenCalled(); + }); + it("sends credentials only to the configured agent without a body or redirects", async () => { + vi.stubEnv("LANGGRAPH_DEPLOYMENT_URL", "agent:8123"); + const fetchMock = vi.fn().mockResolvedValue(Response.json({ ok: true })); + vi.stubGlobal("fetch", fetchMock); + const response = await POST(new Request("http://localhost/api/provider-keys", { method: "POST", headers })); + expect(fetchMock).toHaveBeenCalledWith("http://agent:8123/credentials/validate", expect.objectContaining({ + method: "POST", headers, cache: "no-store", redirect: "error", + })); + expect(fetchMock.mock.calls[0][1]).not.toHaveProperty("body"); + expect(await response.json()).toEqual({ ok: true }); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); + it("does not echo upstream secrets or arbitrary error content", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ error: "openai-test-secret", code: "unknown" }, { status: 401 }))); + const response = await POST(new Request("http://localhost/api/provider-keys", { method: "POST", headers })); + expect(await response.text()).not.toContain("openai-test-secret"); + expect(response.status).toBe(400); + }); + it("provides a specific safe message for rejected Jev keys", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ code: "jev_invalid" }, { status: 401 }))); + const response = await POST(new Request("http://localhost/api/provider-keys", { method: "POST", headers })); + expect((await response.json()).error).toContain("Jev could not validate"); + }); + it("does not expose fetch failures or credentials", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("headers: openai-test-secret"))); + const response = await POST(new Request("http://localhost/api/provider-keys", { method: "POST", headers })); + expect(response.status).toBe(502); expect(await response.text()).not.toContain("openai-test-secret"); + }); +}); diff --git a/apps/app/src/app/api/provider-keys/route.ts b/apps/app/src/app/api/provider-keys/route.ts new file mode 100644 index 0000000..4839b5b --- /dev/null +++ b/apps/app/src/app/api/provider-keys/route.ts @@ -0,0 +1,40 @@ +import { normalizeLanggraphUrl } from "@/lib/copilotkit-runtime-options"; +import { readProviderHeaders, PROVIDER_KEY_INPUT_ERROR } from "@/lib/provider-keys"; + +const errors: Record = { + openai_invalid: "OpenAI could not validate this key for the answer model. Check its permissions and model access.", + jev_invalid: "Jev could not validate this key. Check your Jev API key and account access.", + provider_unavailable: "A provider is unavailable or rate limited. Please try again shortly.", + invalid_keys: PROVIDER_KEY_INPUT_ERROR, +}; +const reply = (body: object, status: number) => Response.json(body, { + status, headers: { "Cache-Control": "no-store" }, +}); + +export async function POST(request: Request) { + let credentials; + try { + credentials = readProviderHeaders(request.headers); + if (!credentials) return reply({ error: PROVIDER_KEY_INPUT_ERROR }, 400); + } catch { + 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, { + method: "POST", + headers: credentials, + cache: "no-store", + redirect: "error", + signal: AbortSignal.timeout(30_000), + }); + const data = await response.json(); + if (response.ok && data?.ok === true) return reply({ ok: true }, 200); + // Never relay arbitrary provider bodies: they can contain sensitive data. + const error = typeof data?.code === "string" ? errors[data.code] : undefined; + return reply({ error: error ?? "The agent could not validate your keys. Please try again." }, 400); + } catch { + return reply({ error: "Could not reach the agent to validate your keys. Please try again." }, 502); + } +} diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx index 91a0533..62b6ff1 100644 --- a/apps/app/src/app/page.tsx +++ b/apps/app/src/app/page.tsx @@ -1,6 +1,8 @@ "use client"; import { useEffect, useRef, useState } from "react"; +import { ProviderKeysDialog } from "@/components/chat/provider-keys-dialog"; +import { useProviderKeys } from "@/components/chat/provider-keys"; import Image from "next/image"; import { ReaderScrollView } from "@/components/chat/reader-scroll-view"; import { AnswerMarkdown } from "@/components/chat/answer-markdown"; @@ -15,6 +17,8 @@ export default function HomePage() { useGenerativeUIExamples(); useExampleSuggestions(); const { agent } = useAgent(); + const { newChat, hasKeys } = useProviderKeys(); + const [keysOpen, setKeysOpen] = useState(false); const { copilotkit } = useCopilotKit(); const [error, setError] = useState(null); const [retrying, setRetrying] = useState(false); @@ -61,8 +65,7 @@ export default function HomePage() { setError(null); try { await copilotkit.runAgent({ agent }); - } catch (cause) { - console.error("Open Intelligent UI retry failed", cause); + } catch { setError( "The agent is unavailable. Check your connection or local agent configuration, then retry.", ); @@ -80,9 +83,12 @@ export default function HomePage() {
Open Intelligent UI
+ {keysOpen && setKeysOpen(false)} disabled={agent.isRunning || retrying} />}
- {error && agent.messages.length > 0 && ( + {error && (

{error}

+
)}
diff --git a/apps/app/src/app/providers.tsx b/apps/app/src/app/providers.tsx index e239353..595a6c6 100644 --- a/apps/app/src/app/providers.tsx +++ b/apps/app/src/app/providers.tsx @@ -7,6 +7,7 @@ import { OPEN_GEN_UI_ACTIVITY_RENDERER } from "@/components/generative-ui/open-g import { SANDBOX_FUNCTIONS } from "@/lib/sandbox/sandbox-functions"; import { OpenGenUIPromptBridge } from "@/lib/sandbox/prompt-bridge"; import { tableCatalog } from "@/components/generative-ui/table-catalog"; +import { ProviderKeysProvider, useProviderKeys } from "@/components/chat/provider-keys"; import { ThemeProvider } from "@/hooks/use-theme"; const a2ui = { catalog: tableCatalog }; @@ -20,19 +21,30 @@ const openGenerativeUI = { export function Providers({ children }: { children: React.ReactNode }) { return ( - - - - {children} - - + + {children} + ); } + +function ChatProviders({ children }: { children: React.ReactNode }) { + const { headers, session } = useProviderKeys(); + return ( + + + + {children} + + + ); +} diff --git a/apps/app/src/components/chat/provider-keys-dialog.tsx b/apps/app/src/components/chat/provider-keys-dialog.tsx new file mode 100644 index 0000000..8f1651e --- /dev/null +++ b/apps/app/src/components/chat/provider-keys-dialog.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useProviderKeys } from "./provider-keys"; +import "./provider-keys.css"; + +export function ProviderKeysDialog({ onClose, disabled = false }: { onClose: () => void; disabled?: boolean }) { + const { hasKeys, save, clear } = useProviderKeys(); + const dialog = useRef(null); + const request = useRef(null); + const [openai, setOpenai] = useState(""); + const [jev, setJev] = useState(""); + const [pending, setPending] = useState(false); + const [validated, setValidated] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const opener = document.activeElement as HTMLElement | null; + dialog.current?.showModal(); + return () => { + request.current?.abort(); + opener?.focus(); + }; + }, []); + + const edit = (setter: (value: string) => void, value: string) => { + setter(value); + setValidated(false); + setError(null); + }; + const testConnection = async () => { + if (pending || disabled || !openai.trim() || !jev.trim()) return; + const controller = new AbortController(); + request.current = controller; + setPending(true); + setValidated(false); + setError(null); + try { + const response = await fetch("/api/provider-keys", { + method: "POST", + headers: { "x-openai-api-key": openai.trim(), "x-jev-api-key": jev.trim() }, + signal: controller.signal, + cache: "no-store", + }); + const result = await response.json(); + if (controller.signal.aborted) return; + if (!response.ok || result.ok !== true) { + // The same-origin proxy maps provider failures to fixed, sanitized messages. + setError(typeof result.error === "string" ? result.error : "Could not validate both keys. Check your keys and provider access, then try again."); + } else { + setValidated(true); + } + } catch { + if (!controller.signal.aborted) setError("Could not connect. Check your connection and try again."); + } finally { + if (!controller.signal.aborted) setPending(false); + } + }; + + return ( + { event.preventDefault(); onClose(); }}> +

API keys

+

Keys stay only in this browser’s memory until you refresh or clear them. They are sent through this app’s server to their respective providers. Provider usage is charged to your accounts.

+

Saving or clearing keys starts a new chat.

+
{ event.preventDefault(); void testConnection(); }}> + + edit(setOpenai, event.target.value)} disabled={pending || disabled} /> + + edit(setJev, event.target.value)} disabled={pending || disabled} /> + {error &&

{error}

} +

{pending ? "Checking both providers…" : validated ? "Both keys work. Save to start a new chat." : hasKeys ? "Your own keys are active for this session." : "Using the app’s shared configuration."}

+
+ {hasKeys && } + + + +
+
+
+ ); +} diff --git a/apps/app/src/components/chat/provider-keys.css b/apps/app/src/components/chat/provider-keys.css new file mode 100644 index 0000000..9742751 --- /dev/null +++ b/apps/app/src/components/chat/provider-keys.css @@ -0,0 +1,27 @@ +.provider-keys-dialog { + margin: auto; + width: min(480px, calc(100vw - 32px)); + max-height: calc(100dvh - 32px); + overflow-y: auto; + padding: 24px; + color: var(--text-primary); + background: var(--surface-primary); + border: 1px solid var(--border-default); + border-radius: 16px; + box-shadow: 0 16px 64px #0003; +} +.provider-keys-dialog::backdrop { background: #0006; } +.provider-keys-dialog h2 { margin: 0 0 12px; font-size: 20px; font-weight: 600; } +.provider-keys-dialog p { margin: 12px 0; font-size: 13px; line-height: 1.6; } +.provider-keys-dialog label { display: block; margin: 16px 0 6px; font-size: 13px; font-weight: 500; } +.provider-keys-dialog input { width: 100%; min-width: 0; border: 1px solid var(--border-default); border-radius: 8px; padding: 10px; background: var(--surface-primary); font-size: 16px; } +.provider-keys-actions { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; margin-top: 20px; } +.provider-keys-actions button { padding: 10px 12px; border: 1px solid var(--border-default); border-radius: 8px; font-size: 12px; cursor: pointer; } +.provider-keys-actions .provider-keys-save { background: var(--text-primary); color: var(--surface-primary); } +.provider-keys-actions button:disabled, .chat-header button:disabled { opacity: .45; cursor: not-allowed; } +.provider-keys-dialog :focus-visible { outline: 2px solid #189370; outline-offset: 3px; } +.chat-error .provider-keys-recover { margin-left: 16px; } +@media (max-width: 600px) { + .chat-header { height: auto; min-height: 76px; flex-wrap: wrap; padding: 12px 16px; gap: 12px; } + .chat-header nav { flex-wrap: wrap; gap: 16px; } +} diff --git a/apps/app/src/components/chat/provider-keys.test.tsx b/apps/app/src/components/chat/provider-keys.test.tsx new file mode 100644 index 0000000..10ea040 --- /dev/null +++ b/apps/app/src/components/chat/provider-keys.test.tsx @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom/vitest"; +import { useState } from "react"; +import { ProviderKeysProvider, useProviderKeys } from "./provider-keys"; +import { ProviderKeysDialog } from "./provider-keys-dialog"; + +function Harness({ disabled = false }: { disabled?: boolean }) { + const { hasKeys, session, headers, newChat } = useProviderKeys(); + const [open, setOpen] = useState(false); + return <> + + + {session} + {hasKeys ? headers?.["x-openai-api-key"] : "shared"} + {open && setOpen(false)} />} + ; +} +function setup(disabled = false) { + render(); + screen.getByText("Open keys").focus(); + fireEvent.click(screen.getByText("Open keys")); +} +function enterKeys() { + fireEvent.change(screen.getByLabelText("OpenAI API key"), { target: { value: " openai-secret " } }); + fireEvent.change(screen.getByLabelText("Jev API key"), { target: { value: " jev-secret " } }); +} +beforeEach(() => { + HTMLDialogElement.prototype.showModal = function () { this.setAttribute("open", ""); }; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ ok: true }) })); +}); +afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + +describe("memory-only provider keys", () => { + it("validates trimmed headers before saving, resets the chat and never persists keys", async () => { + const storage = vi.spyOn(Storage.prototype, "setItem"); + const cookie = vi.spyOn(document, "cookie", "set"); + setup(); + expect(screen.getByRole("dialog")).toHaveAccessibleName("API keys"); + expect(screen.getByLabelText("OpenAI API key")).toHaveAttribute("type", "password"); + expect(screen.getByRole("button", { name: "Save keys" })).toBeDisabled(); + enterKeys(); + fireEvent.click(screen.getByText("Test connection")); + await screen.findByText("Both keys work. Save to start a new chat."); + expect(fetch).toHaveBeenCalledWith("/api/provider-keys", expect.objectContaining({ method: "POST", headers: { "x-openai-api-key": "openai-secret", "x-jev-api-key": "jev-secret" }, cache: "no-store" })); + expect(screen.getByTestId("active")).toHaveTextContent("shared"); + fireEvent.click(screen.getByText("Save keys")); + expect(screen.getByTestId("session")).toHaveTextContent("1"); + expect(screen.getByTestId("active")).toHaveTextContent("openai-secret"); + fireEvent.click(screen.getByText("New chat")); + expect(screen.getByTestId("session")).toHaveTextContent("2"); + expect(screen.getByTestId("active")).toHaveTextContent("openai-secret"); + expect(storage).not.toHaveBeenCalled(); + expect(cookie).not.toHaveBeenCalled(); + fireEvent.click(screen.getByText("Open keys")); + expect(screen.getByLabelText("OpenAI API key")).toHaveValue(""); + fireEvent.click(screen.getByText("Clear keys")); + expect(screen.getByTestId("active")).toHaveTextContent("shared"); + expect(screen.getByTestId("session")).toHaveTextContent("3"); + cleanup(); + setup(); + expect(screen.getByTestId("active")).toHaveTextContent("shared"); + }); + + it("shows sanitized proxy failures without saving", async () => { + vi.mocked(fetch).mockResolvedValue({ ok: false, json: async () => ({ error: "OpenAI key could not be validated." }) } as Response); + setup(); enterKeys(); fireEvent.click(screen.getByText("Test connection")); + expect(await screen.findByRole("alert")).toHaveTextContent("OpenAI key could not be validated."); + expect(screen.getByText("Save keys")).toBeDisabled(); + expect(screen.getByTestId("active")).toHaveTextContent("shared"); + }); + + it("invalidates validation after edits and clears drafts on cancel with focus restored", async () => { + setup(); enterKeys(); fireEvent.click(screen.getByText("Test connection")); + await screen.findByText("Both keys work. Save to start a new chat."); + fireEvent.change(screen.getByLabelText("Jev API key"), { target: { value: "new-secret" } }); + expect(screen.getByText("Save keys")).toBeDisabled(); + fireEvent.click(screen.getByText("Cancel")); + expect(screen.getByText("Open keys")).toHaveFocus(); + fireEvent.click(screen.getByText("Open keys")); + expect(screen.getByLabelText("Jev API key")).toHaveValue(""); + }); + + it("disables credential changes during a run", () => { + setup(true); + expect(screen.getByLabelText("OpenAI API key")).toBeDisabled(); + expect(screen.getByText("Test connection")).toBeDisabled(); + expect(screen.getByText("Save keys")).toBeDisabled(); + }); + + it("aborts pending validation when closed", async () => { + vi.mocked(fetch).mockImplementation(() => new Promise(() => {})); + setup(); enterKeys(); fireEvent.click(screen.getByText("Test connection")); + await waitFor(() => expect(fetch).toHaveBeenCalled()); + const options = vi.mocked(fetch).mock.calls[0][1]; + expect(screen.getByText("Checking both providers…")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Cancel")); + expect(options?.signal?.aborted).toBe(true); + }); +}); diff --git a/apps/app/src/components/chat/provider-keys.tsx b/apps/app/src/components/chat/provider-keys.tsx new file mode 100644 index 0000000..6bc0f0b --- /dev/null +++ b/apps/app/src/components/chat/provider-keys.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { createContext, useContext, useState, type ReactNode } from "react"; + +export type ProviderKeys = { openai: string; jev: string }; +type ProviderKeysContextValue = { + headers: Record | undefined; + hasKeys: boolean; + session: number; + save: (keys: ProviderKeys) => void; + clear: () => void; + newChat: () => void; +}; +const ProviderKeysContext = createContext(null); + +export function ProviderKeysProvider({ children }: { children: ReactNode }) { + // Deliberately memory-only: never put credentials in browser storage or agent state. + const [keys, setKeys] = useState(null); + const [session, setSession] = useState(0); + const newChat = () => setSession((value) => value + 1); + return ( + { setKeys(value); newChat(); }, + clear: () => { setKeys(null); newChat(); }, + newChat, + }}> + {children} + + ); +} + +export function useProviderKeys() { + const value = useContext(ProviderKeysContext); + if (!value) throw new Error("ProviderKeysProvider is required"); + return value; +} diff --git a/apps/app/src/lib/__tests__/copilotkit-runtime-options.test.ts b/apps/app/src/lib/__tests__/copilotkit-runtime-options.test.ts index 4f05219..56f3f67 100644 --- a/apps/app/src/lib/__tests__/copilotkit-runtime-options.test.ts +++ b/apps/app/src/lib/__tests__/copilotkit-runtime-options.test.ts @@ -3,6 +3,14 @@ import { LangGraphHttpAgent } from "@copilotkit/runtime/langgraph"; import { buildRuntimeOptions } from "../copilotkit-runtime-options"; describe("buildRuntimeOptions", () => { + it("isolates credentials between runtime instances", () => { + const first = buildRuntimeOptions({ providerHeaders: { "x-openai-api-key": "first", "x-jev-api-key": "first-jev" } }); + const second = buildRuntimeOptions({ providerHeaders: { "x-openai-api-key": "second", "x-jev-api-key": "second-jev" } }); + expect(first.agents.default.headers["x-openai-api-key"]).toBe("first"); + expect(second.agents.default.headers["x-openai-api-key"]).toBe("second"); + expect(buildRuntimeOptions({}).agents.default.headers).not.toHaveProperty("x-openai-api-key"); + }); + it("enables openGenerativeUI", () => { expect(buildRuntimeOptions({}).openGenerativeUI).toBe(true); }); diff --git a/apps/app/src/lib/__tests__/credential-redirect.test.ts b/apps/app/src/lib/__tests__/credential-redirect.test.ts new file mode 100644 index 0000000..054a5be --- /dev/null +++ b/apps/app/src/lib/__tests__/credential-redirect.test.ts @@ -0,0 +1,37 @@ +// @vitest-environment node +import { createServer } from "node:http"; +import { once } from "node:events"; +import { expect, it } from "vitest"; +import { buildRuntimeOptions } from "../copilotkit-runtime-options"; + +it("never forwards credential headers to a redirected agent destination, including clones", async () => { + let leakedRequests = 0; + const destination = createServer((_request, response) => { + leakedRequests++; + response.end(); + }); + destination.listen(0, "127.0.0.1"); + await once(destination, "listening"); + const target = destination.address(); + if (!target || typeof target === "string") throw new Error("Expected TCP address"); + const origin = createServer((_request, response) => { + response.writeHead(307, { Location: `http://127.0.0.1:${target.port}/` }); + response.end(); + }); + origin.listen(0, "127.0.0.1"); + await once(origin, "listening"); + const address = origin.address(); + if (!address || typeof address === "string") throw new Error("Expected TCP address"); + try { + const agent = buildRuntimeOptions({ + langgraphUrl: `http://127.0.0.1:${address.port}/`, + providerHeaders: { "x-openai-api-key": "visitor-key", "x-jev-api-key": "visitor-jev" }, + }).agents.default; + await expect(agent.clone().runAgent()).rejects.toThrow(); + expect(leakedRequests).toBe(0); + } finally { + origin.closeAllConnections(); + destination.closeAllConnections(); + await Promise.all([new Promise((resolve) => origin.close(() => resolve())), new Promise((resolve) => destination.close(() => resolve()))]); + } +}); diff --git a/apps/app/src/lib/__tests__/provider-keys.test.ts b/apps/app/src/lib/__tests__/provider-keys.test.ts new file mode 100644 index 0000000..51ef428 --- /dev/null +++ b/apps/app/src/lib/__tests__/provider-keys.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { readProviderHeaders } from "../provider-keys"; + +describe("request-scoped provider credentials", () => { + it("leaves server mode unchanged when no keys are supplied", () => { + expect(readProviderHeaders(new Headers())).toBeUndefined(); + }); + it("forwards only the two trimmed provider keys", () => { + expect(readProviderHeaders(new Headers({ + "x-openai-api-key": " openai-secret ", + "x-jev-api-key": "jev-secret", + authorization: "unrelated-secret", + }))).toEqual({ "x-openai-api-key": "openai-secret", "x-jev-api-key": "jev-secret" }); + }); + it.each(["", "has space", "x".repeat(4097)])("rejects invalid pairs without echoing keys", (key) => { + expect(() => readProviderHeaders(new Headers({"x-openai-api-key": key, "x-jev-api-key": "jev-secret"}))).toThrow("Enter both"); + }); + it("rejects a missing partner", () => { + expect(() => readProviderHeaders(new Headers({"x-openai-api-key": "secret"}))).toThrow("Enter both"); + }); +}); diff --git a/apps/app/src/lib/copilotkit-runtime-options.ts b/apps/app/src/lib/copilotkit-runtime-options.ts index 6ccac9e..0e34681 100644 --- a/apps/app/src/lib/copilotkit-runtime-options.ts +++ b/apps/app/src/lib/copilotkit-runtime-options.ts @@ -1,12 +1,22 @@ import { LangGraphHttpAgent } from "@copilotkit/runtime/langgraph"; +import type { ProviderHeaders } from "./provider-keys"; + +class CredentialSafeLangGraphAgent extends LangGraphHttpAgent { + protected override requestInit(input: Parameters[0]): RequestInit { + // Custom credential headers survive cross-origin fetch redirects. Fail closed. + return { ...super.requestInit(input), redirect: "error" }; + } +} + export interface RuntimeOptionsEnv { langgraphUrl?: string; mcpServerUrl?: string; + providerHeaders?: ProviderHeaders; } // Normalize Render's fromService hostport (bare host:port) into a full URL -function normalizeLanggraphUrl(raw?: string): string { +export function normalizeLanggraphUrl(raw?: string): string { if (!raw) return "http://localhost:8123"; return raw.startsWith("http") ? raw : `http://${raw}`; } @@ -14,8 +24,9 @@ function normalizeLanggraphUrl(raw?: string): string { export function buildRuntimeOptions(env: RuntimeOptionsEnv) { return { agents: { - default: new LangGraphHttpAgent({ + default: new CredentialSafeLangGraphAgent({ url: normalizeLanggraphUrl(env.langgraphUrl), + ...(env.providerHeaders && { headers: env.providerHeaders }), }), }, a2ui: { injectA2UITool: true }, diff --git a/apps/app/src/lib/provider-keys.ts b/apps/app/src/lib/provider-keys.ts new file mode 100644 index 0000000..e39a29d --- /dev/null +++ b/apps/app/src/lib/provider-keys.ts @@ -0,0 +1,17 @@ +export type ProviderHeaders = { + "x-openai-api-key": string; + "x-jev-api-key": string; +}; + +export const PROVIDER_KEY_INPUT_ERROR = "Enter both OpenAI and Jev API keys (up to 4,096 characters each, without spaces)."; + +// Credentials travel only as explicitly selected headers, never as agent state. +export function readProviderHeaders(headers: Headers): ProviderHeaders | undefined { + const openai = headers.get("x-openai-api-key"); + const jev = headers.get("x-jev-api-key"); + if (openai === null && jev === null) return undefined; + const valid = (value: string | null): value is string => + value !== null && /^[\x21-\x7e]{1,4096}$/.test(value.trim()); + if (!valid(openai) || !valid(jev)) throw new Error(PROVIDER_KEY_INPUT_ERROR); + return { "x-openai-api-key": openai.trim(), "x-jev-api-key": jev.trim() }; +} diff --git a/docs/deployment.md b/docs/deployment.md index 7b58771..4dbb8a9 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -11,7 +11,7 @@ The repository's `render.yaml` defines two services: | `open-generative-ui-agent` | Python 3.12.6, `apps/agent` | `pip install uv && uv sync` | `uv run uvicorn main:app --host 0.0.0.0 --port $PORT`, `/health` | | `open-generative-ui-app` | Node 22, repository root | `corepack enable && pnpm install --no-frozen-lockfile && pnpm exec turbo run build --filter=@repo/app` | `pnpm --filter @repo/app start`, `/api/health` | -The blueprint and Python factory default to `LLM_MODEL=chat-latest`, requiring `OPENAI_API_KEY`. Jev visualization routing additionally requires `TYPESAFE_API_KEY`; `JEV_MODEL` defaults to `jev-latest`. Verify access to the selected model before launch; a configured model name does not prove availability. +For shared credentials, the blueprint and Python factory default to `LLM_MODEL=chat-latest`, using `OPENAI_API_KEY`. Jev visualization routing uses `TYPESAFE_API_KEY`; `JEV_MODEL` defaults to `jev-latest`. Verify access to the selected model before launch; a configured model name does not prove availability. | Variable | Service | Purpose | | ----------------------------------------- | ------------- | ----------------------------------------------------------------------------------- | @@ -24,12 +24,18 @@ The blueprint and Python factory default to `LLM_MODEL=chat-latest`, requiring ` | `RATE_LIMIT_WINDOW_MS` / `RATE_LIMIT_MAX` | Frontend | Request-limit configuration | | `MCP_SERVER_URL` | Frontend | Optional MCP integration | -The agent fails at startup for blank/unsupported model prefixes or missing/blank selected-provider keys. Never put provider keys in public frontend variables. +The agent rejects blank or unsupported model names at startup. Missing shared provider keys allow a BYOK-only deployment; requests without visitor keys receive a credentials-required response. Never put provider keys in public frontend variables. To deploy, connect your repository to a Render Blueprint, review its explicit model setting, supply the corresponding provider secret, and deploy the reviewed revision. The blueprint links the frontend to the agent; verify the resulting connection with an actual prompt. Both services declare scaling from one to three instances. Agent checkpoints currently live in bounded process memory, and the frontend rate limiter is process-local. Scaling or restarting can lose/split conversation state and changes effective rate-limit behavior. Durable shared state and coordinated limits require additional infrastructure. +## Visitor API keys + +Visitors can supply OpenAI and Jev keys through **API keys** in the chat header. The Next.js runtime forwards only `x-openai-api-key` and `x-jev-api-key` to the configured agent, creating a separate runtime instance per request. The Python ASGI middleware strips these headers before the inner application and scopes credentials to the entire streaming request without changing process environment variables. BYOK conversations have credential-specific checkpoint IDs, separate from shared-mode conversations. Both keys are required; partial pairs never fall back to server secrets. + +`POST /api/provider-keys` proxies connection validation to the agent’s `POST /credentials/validate`. It performs small real provider requests and returns only fixed error messages. BYOK always uses the standard OpenAI endpoint and `chat-latest`; Jev uses `jev-latest`. Hosted LangSmith tracing is disabled for BYOK. The application does not persist the keys, but its server necessarily receives them: use HTTPS externally and a trusted private or TLS connection between frontend and agent. Do not configure infrastructure to log credential headers. + ## Other hosts Run the agent: diff --git a/docs/getting-started.md b/docs/getting-started.md index c5d7e72..0633134 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -14,7 +14,9 @@ make setup ## Configure the agent -Edit `apps/agent/.env`. For the default configuration: +You can start the agent without shared provider keys and use **API keys** in the chat header to test and save your own OpenAI and Jev keys for the current browser session. Refreshing clears the keys. + +To provide shared credentials instead, edit `apps/agent/.env`: ```dotenv OPENAI_API_KEY=your-provider-key @@ -23,9 +25,9 @@ TYPESAFE_API_KEY=your-typesafe-key JEV_MODEL=jev-latest ``` -The model factory accepts `claude-*` names through Anthropic and `chat-latest` or `gpt-*` names through OpenAI. To override the default, set a model available to your account in `LLM_MODEL` and supply its provider key. The selected answer provider’s key and the Jev key are required. Unset `LLM_MODEL` uses the local default; an empty value or unsupported prefix fails with a configuration error. +The model factory accepts `claude-*` names through Anthropic and `chat-latest` or `gpt-*` names through OpenAI. To override the default, set a model available to your account in `LLM_MODEL` and supply its provider key. Shared mode requires the selected answer provider’s key and the Jev key; BYOK requests supply their own pair. Unset `LLM_MODEL` uses the local default; an empty value or unsupported prefix fails with a configuration error. -Check model availability and access with a real request. This repository does not guarantee that every model name works or implement a separate native GPT-6 API. Keep provider keys on the agent server. +Check model availability and access with a real request. This repository does not guarantee that every model name works or implement a separate native GPT-6 API. Never put shared provider keys in public frontend environment variables. See [BYOK behavior](../README.md#use-your-own-api-keys). ## Configure the frontend