Skip to content

Commit efaa73d

Browse files
authored
Tanvir/add integrations for vercel and fai (#4831)
1 parent 2927317 commit efaa73d

File tree

13 files changed

+778
-104
lines changed

13 files changed

+778
-104
lines changed

servers/oculus/.env.example

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,18 @@
1+
# Required for all integrations
12
ANTHROPIC_API_KEY=your-api-key-here
23
OPENAI_API_KEY=your-api-key-here
34
TURBOPUFFER_API_KEY=your-api-key-here
4-
FERN_TOKEN=your_fern_token
5+
FERN_TOKEN=your_fern_token
6+
7+
# Integration selection (optional, defaults to fai-local)
8+
# Options: fai-local, fai-http, vercel-http
9+
OCULUS_INTEGRATION=fai-local
10+
11+
# FAI HTTP integration (required if using fai-http)
12+
FAI_URL=https://fai.buildwithfern.com
13+
14+
# Vercel HTTP integration (required if using vercel-http)
15+
# Production: https://buildwithfern.com
16+
# Staging: https://fern.docs.staging.buildwithfern.com
17+
# Preview: https://preview-xyz.vercel.app
18+
VERCEL_URL=https://buildwithfern.com

servers/oculus/README.md

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,55 @@
33
The `oculus` subrepository is used to generate and run evaluations on Ask Fern. There are three main components:
44

55
- `generators` create an evaluation set (can either be programmatic or stochastic).
6-
- `integrations` invoke various forms of the `/chat` endpoint (API, Slack, Discord, etc.).
6+
- `integrations` invoke various forms of the answer generation system to get responses.
77
- `evaluators` run on output responses to measure performance on various metrics.
88

9+
## Integration Types
10+
11+
Oculus supports multiple integration types for generating answers:
12+
13+
### `fai-local` (Default)
14+
- Calls FAI functions directly in-process (Python imports)
15+
- **Fastest** - No network overhead
16+
- **Best for:** Development, rapid iteration, large-scale experiments
17+
- **Tests:** FAI code logic only (not production deployment)
18+
19+
### `fai-http`
20+
- Calls FAI `/chat/{domain}` HTTP endpoint
21+
- **Medium speed** - Some network overhead
22+
- **Best for:** Testing FAI service specifically, validating FAI API
23+
- **Tests:** FAI service deployment (but not full production stack)
24+
25+
### `vercel-http`
26+
- Calls Vercel `/api/fern-docs/search/v2/chat` endpoint (configured via `VERCEL_URL`)
27+
- **Slowest** - Full production stack with all middleware
28+
- **Best for:** Production validation, pre-release testing, staging tests
29+
- **Tests:** Exact production experience (auth, tool calling, etc.)
30+
- **Requires:** `VERCEL_URL` environment variable
31+
32+
### Selecting Integration
33+
34+
**Via environment variable:**
35+
```bash
36+
export OCULUS_INTEGRATION=vercel-http
37+
oculus answer --suite simple
38+
```
39+
40+
**Via CLI argument:**
41+
```bash
42+
oculus answer --suite simple --integration fai-http
43+
oculus run --suite simple --integration vercel-http
44+
```
45+
46+
**Comparison workflow:**
47+
```bash
48+
# Run same eval with all three integrations
49+
for integration in fai-local fai-http vercel-http; do
50+
oculus answer --suite simple --run-id ${integration}_test --integration $integration
51+
oculus evaluate --suite simple --run-id ${integration}_test
52+
done
53+
```
54+
955
## Commands
1056

1157
### Generate questions for a suite
@@ -14,12 +60,17 @@ The `oculus` subrepository is used to generate and run evaluations on Ask Fern.
1460

1561
### Run full evaluation pipeline (generate answers + evaluate)
1662

17-
oculus run --suite {suite_name} [--run-id {id}] [--model {model}]
63+
oculus run --suite {suite_name} [--run-id {id}] [--integration {type}] [--model {model}]
1864

1965
### Generate answers only
2066

21-
oculus answer --suite {suite_name} [--run-id {id}] [--model {model}]
67+
oculus answer --suite {suite_name} [--run-id {id}] [--integration {type}] [--model {model}]
2268

2369
### Evaluate existing answers
2470

2571
oculus evaluate --suite {suite_name} --run-id {id} [--judge-model {model}]
72+
73+
**Options:**
74+
- `--integration`: One of `fai-local`, `fai-http`, `vercel-http` (defaults to `OCULUS_INTEGRATION` env var or `fai-local`)
75+
- `--model`: One of `claude-4-sonnet-20250514`, `command-a-03-2025` (default: `claude-4-sonnet-20250514`)
76+
- `--judge-model`: Claude model for evaluation judging (default: `claude-opus-4-20250514`)

servers/oculus/pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ oculus = "oculus.__main__:main"
3232
requires = ["poetry-core"]
3333
build-backend = "poetry.core.masonry.api"
3434

35+
[tool.pytest.ini_options]
36+
testpaths = ["tests"]
37+
python_files = ["test_*.py"]
38+
python_classes = ["Test*"]
39+
python_functions = ["test_*"]
40+
3541
[tool.mypy]
3642
python_version = "3.11"
3743
strict = true

servers/oculus/src/oculus/__main__.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
SuiteConfig,
1515
)
1616
from oculus.framework.runner import EvaluationRunner
17-
from oculus.integrations.fai_integration import create_fai_answer_function
17+
from oculus.integrations.base import create_answer_function
18+
from oculus.integrations.factory import create_integration, get_default_integration_type
1819
from oculus.utils.file_utils import (
1920
load_json,
2021
save_json,
@@ -99,8 +100,12 @@ def generate_answers_command(args: argparse.Namespace) -> int:
99100
return 1
100101

101102
try:
102-
print(f"Initializing FAI integration for domain: {suite_config.domain}")
103-
answer_fn = create_fai_answer_function(domain=suite_config.domain, model=args.model)
103+
integration_type = args.integration if hasattr(args, "integration") else get_default_integration_type()
104+
print(f"Initializing {integration_type} integration for domain: {suite_config.domain}")
105+
integration = create_integration(
106+
integration_type=integration_type, domain=suite_config.domain, model=args.model
107+
)
108+
answer_fn = create_answer_function(integration)
104109

105110
runner = EvaluationRunner(
106111
suite_name=args.suite,
@@ -304,8 +309,12 @@ def run_evaluation(args: argparse.Namespace) -> int:
304309
return 1
305310

306311
try:
307-
print(f"Initializing FAI integration for domain: {suite_config.domain}")
308-
answer_fn = create_fai_answer_function(domain=suite_config.domain, model=args.model)
312+
integration_type = args.integration if hasattr(args, "integration") else get_default_integration_type()
313+
print(f"Initializing {integration_type} integration for domain: {suite_config.domain}")
314+
integration = create_integration(
315+
integration_type=integration_type, domain=suite_config.domain, model=args.model
316+
)
317+
answer_fn = create_answer_function(integration)
309318

310319
runner = EvaluationRunner(
311320
suite_name=args.suite,
@@ -385,6 +394,13 @@ def main() -> int:
385394
answer_parser.add_argument("--suite", type=str, required=True, help="Name of the evaluation suite")
386395
answer_parser.add_argument("--suite-path", type=Path, default=None, help="Base path to suites directory")
387396
answer_parser.add_argument("--run-id", type=str, default=None, help="Unique run identifier")
397+
answer_parser.add_argument(
398+
"--integration",
399+
type=str,
400+
default=None,
401+
choices=["fai-local", "fai-http", "vercel-http"],
402+
help="Integration type (defaults to OCULUS_INTEGRATION env var or fai-local)",
403+
)
388404
answer_parser.add_argument(
389405
"--model",
390406
type=str,
@@ -431,6 +447,13 @@ def main() -> int:
431447
run_parser.add_argument("--suite", type=str, required=True, help="Name of the evaluation suite")
432448
run_parser.add_argument("--suite-path", type=Path, default=None, help="Base path to suites directory")
433449
run_parser.add_argument("--run-id", type=str, default=None, help="Unique run identifier")
450+
run_parser.add_argument(
451+
"--integration",
452+
type=str,
453+
default=None,
454+
choices=["fai-local", "fai-http", "vercel-http"],
455+
help="Integration type (defaults to OCULUS_INTEGRATION env var or fai-local)",
456+
)
434457
run_parser.add_argument(
435458
"--model",
436459
type=str,

servers/oculus/src/oculus/generators/endpoints.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,10 @@ def generate_openapi_questions(
202202

203203
completed += 1
204204
if num_questions is not None:
205-
print(f"Progress: {len(questions)}/{num_questions} - Generated questions for {method.upper()} {path}")
205+
print(
206+
f"Progress: {len(questions)}/{num_questions} - "
207+
f"Generated questions for {method.upper()} {path}"
208+
)
206209
else:
207210
print(f"Progress: {completed}/{total_endpoints} - Generated questions for {method.upper()} {path}")
208211

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import json
2+
from collections.abc import Callable
3+
from typing import Protocol, TypedDict
4+
5+
6+
class SourceMetadata(TypedDict, total=False):
7+
"""Normalized source metadata across integrations."""
8+
9+
title: str | None
10+
url: str | None
11+
slug: str | None
12+
13+
14+
class AnswerMetadata(TypedDict, total=False):
15+
"""
16+
Common metadata structure for all integrations.
17+
18+
Required fields:
19+
- integration_type: The type of integration used
20+
- model: The model used to generate the answer
21+
- sources: List of source documents used
22+
23+
Optional fields:
24+
- fai_local_retrieved_documents: Full JSON with retrieval scores (FAI local only)
25+
- fai_http_citations: List of citation URLs (FAI HTTP only)
26+
- vercel_query_id: Query tracking ID (Vercel only)
27+
- vercel_tool_calls: Number of tool calls made (Vercel only)
28+
- response_time_ms: Time taken to generate response
29+
"""
30+
31+
# Required
32+
integration_type: str
33+
model: str
34+
sources: list[SourceMetadata]
35+
36+
# Optional - Integration-specific
37+
fai_local_retrieved_documents: str
38+
fai_http_citations: list[str]
39+
vercel_query_id: str
40+
vercel_tool_calls: int
41+
42+
# Optional - Timing
43+
response_time_ms: float
44+
45+
46+
class AnswerIntegration(Protocol):
47+
"""Protocol for generating answers from different sources."""
48+
49+
def generate_answer(self, question: str) -> tuple[str, AnswerMetadata]:
50+
"""
51+
Generate an answer for the given question.
52+
53+
Args:
54+
question: The question to answer
55+
56+
Returns:
57+
A tuple of (answer_text, metadata)
58+
- answer_text: The generated answer
59+
- metadata: Metadata about the answer following AnswerMetadata schema
60+
"""
61+
...
62+
63+
64+
def create_answer_function(
65+
integration: AnswerIntegration,
66+
) -> Callable[[str], tuple[str, dict[str, str]]]:
67+
"""Helper to create a simple callable from an integration."""
68+
69+
def answer_fn(question: str) -> tuple[str, dict[str, str]]:
70+
answer, metadata = integration.generate_answer(question)
71+
result = {}
72+
for k, v in metadata.items():
73+
if isinstance(v, list | dict):
74+
result[k] = json.dumps(v)
75+
else:
76+
result[k] = str(v)
77+
return answer, result
78+
79+
return answer_fn
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import os
2+
3+
from oculus.integrations.base import AnswerIntegration
4+
from oculus.integrations.fai_http import FAIHTTPIntegration
5+
from oculus.integrations.fai_local import FAILocalIntegration
6+
from oculus.integrations.vercel_http import VercelHTTPIntegration
7+
8+
INTEGRATION_TYPES = ["fai-local", "fai-http", "vercel-http"]
9+
10+
11+
def get_default_integration_type() -> str:
12+
return os.environ.get("OCULUS_INTEGRATION", "fai-local")
13+
14+
15+
def create_integration(
16+
integration_type: str | None = None,
17+
domain: str = "",
18+
model: str = "claude-4-sonnet-20250514",
19+
system_prompt: str | None = None,
20+
**kwargs: object,
21+
) -> AnswerIntegration:
22+
"""
23+
Create an answer integration based on type.
24+
25+
Args:
26+
integration_type: Type of integration ("fai-local", "fai-http", "vercel-http")
27+
If None, uses OCULUS_INTEGRATION env var or defaults to "fai-local"
28+
domain: Documentation domain
29+
model: Model to use for generation
30+
system_prompt: Optional system prompt override
31+
**kwargs: Additional integration-specific arguments
32+
33+
Returns:
34+
An AnswerIntegration instance
35+
36+
Raises:
37+
ValueError: If integration_type is not supported
38+
39+
Environment Variables:
40+
OCULUS_INTEGRATION: Default integration type (if integration_type not specified)
41+
FAI_URL: Base URL for FAI service (for fai-http)
42+
FERN_TOKEN: Auth token for FAI (for fai-http)
43+
VERCEL_URL: Base URL for Vercel docs site (for vercel-http, required)
44+
"""
45+
# Use default if not specified
46+
if integration_type is None:
47+
integration_type = get_default_integration_type()
48+
49+
integration_type = integration_type.lower()
50+
51+
if integration_type == "fai-local":
52+
return FAILocalIntegration(domain=domain, model=model, system_prompt=system_prompt)
53+
54+
elif integration_type == "fai-http":
55+
fai_url_kwarg = kwargs.get("fai_url")
56+
fai_url = (fai_url_kwarg if isinstance(fai_url_kwarg, str) else None) or os.environ.get("FAI_URL")
57+
fern_token_kwarg = kwargs.get("fern_token")
58+
fern_token = (fern_token_kwarg if isinstance(fern_token_kwarg, str) else None) or os.environ.get("FERN_TOKEN")
59+
return FAIHTTPIntegration(
60+
domain=domain,
61+
model=model,
62+
system_prompt=system_prompt,
63+
fai_url=fai_url,
64+
fern_token=fern_token,
65+
)
66+
67+
elif integration_type == "vercel-http":
68+
vercel_url_kwarg = kwargs.get("vercel_url")
69+
vercel_url = (vercel_url_kwarg if isinstance(vercel_url_kwarg, str) else None) or os.environ.get("VERCEL_URL")
70+
return VercelHTTPIntegration(
71+
domain=domain,
72+
model=model,
73+
system_prompt=system_prompt,
74+
vercel_url=vercel_url,
75+
)
76+
77+
else:
78+
raise ValueError(
79+
f"Unsupported integration type: {integration_type}. " f"Supported types: {', '.join(INTEGRATION_TYPES)}"
80+
)

0 commit comments

Comments
 (0)