jev-aicore 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: jev-aicore
3
+ Version: 1.0.0
4
+ Summary: Call Jev via SAP AI Core — one function, zero setup
5
+ Requires-Python: >=3.10
@@ -0,0 +1,446 @@
1
+ # jev-aicore-proxy
2
+
3
+ A lightweight FastAPI proxy that exposes TypeSafe Jev 1.13 (via OpenRouter) through an SAP AI Core managed inference endpoint.
4
+
5
+ This is a PoC. The goal is to verify that structured Jev System One inference can be reached through SAP AI Core before committing to a separate Jev subscription.
6
+
7
+ ---
8
+
9
+ ## Architecture
10
+
11
+ ```mermaid
12
+ sequenceDiagram
13
+ participant Client as Application / Test Client
14
+ participant AIC as SAP AI Core Endpoint
15
+ participant Proxy as jev-aicore-proxy (FastAPI)
16
+ participant OR as OpenRouter
17
+ participant Jev as TypeSafe Jev 1.13
18
+
19
+ Client->>AIC: POST /v1/systemone<br/>(SAP AI Core bearer token)
20
+ AIC->>Proxy: forward request
21
+ Proxy->>OR: TypeSafe SDK system_one()<br/>(OPENROUTER_API_KEY — injected secret)
22
+ OR->>Jev: System One inference
23
+ Jev-->>OR: Structured answers
24
+ OR-->>Proxy: SystemOneResponse
25
+ Proxy-->>AIC: JSON { model, usage, answers }
26
+ AIC-->>Client: same JSON
27
+ ```
28
+
29
+ **Authentication boundaries:**
30
+ - The client authenticates to SAP AI Core with a bearer token obtained from your BTP XSUAA instance.
31
+ - The proxy authenticates to OpenRouter with the `OPENROUTER_API_KEY` injected as a Generic Secret by SAP AI Core at deployment time.
32
+ - The client never sees or needs the OpenRouter key.
33
+
34
+ ---
35
+
36
+ ## Project structure
37
+
38
+ ```
39
+ jev-aicore-proxy/
40
+ ├── server.py FastAPI application
41
+ ├── requirements.txt Runtime dependencies (pinned)
42
+ ├── Dockerfile Container image definition
43
+ ├── .dockerignore
44
+ ├── .gitignore
45
+ ├── .env.example Placeholder for local development
46
+ ├── pytest.ini
47
+ ├── tests/
48
+ │ └── test_server.py Unit tests (mocked) + integration test
49
+ └── deployment/
50
+ └── jev-serving.yaml SAP AI Core ServingTemplate
51
+ ```
52
+
53
+ ---
54
+
55
+ ## Prerequisites
56
+
57
+ | Requirement | Notes |
58
+ |---|---|
59
+ | Python 3.12 | Tested with 3.12.14 |
60
+ | typesafe-sdk 0.7.5 | Installed from PyPI |
61
+ | OpenRouter API key | `OPENROUTER_API_KEY` |
62
+ | Docker (optional) | For container build |
63
+ | SAP AI Core access | For actual deployment |
64
+
65
+ ---
66
+
67
+ ## Local setup
68
+
69
+ ```bash
70
+ # Clone / open the project
71
+ cd jev-aicore-proxy
72
+
73
+ # Install all dependencies (runtime + dev)
74
+ pip install -r requirements.txt pytest pytest-mock pytest-asyncio httpx
75
+
76
+ # Copy the example env file and add your key
77
+ cp .env.example .env
78
+ # Edit .env and set OPENROUTER_API_KEY=sk-or-v1-...
79
+ ```
80
+
81
+ ---
82
+
83
+ ## Environment configuration
84
+
85
+ | Variable | Required | Description |
86
+ |---|---|---|
87
+ | `OPENROUTER_API_KEY` | Yes | OpenRouter API key for Jev model access |
88
+
89
+ The application refuses to start if the key is missing.
90
+
91
+ **Never commit `.env`** — it is listed in `.gitignore`.
92
+
93
+ ---
94
+
95
+ ## API endpoints
96
+
97
+ ### `GET /v2/health`
98
+
99
+ Returns `{"status": "ok"}`. Does not call OpenRouter.
100
+
101
+ ```bash
102
+ curl http://localhost:8080/v2/health
103
+ ```
104
+
105
+ ### `POST /v1/systemone`
106
+
107
+ Runs a Jev System One structured decision request.
108
+
109
+ **Request body:**
110
+
111
+ ```json
112
+ {
113
+ "state": {
114
+ "customer_tier": "enterprise",
115
+ "ticket": "Checkout page shows blank screen after clicking Pay."
116
+ },
117
+ "questions": {
118
+ "is_bug": {
119
+ "type": "noul",
120
+ "instructions": "Is the customer reporting a software defect?",
121
+ "criteria": {
122
+ "true": "The product behaves unexpectedly.",
123
+ "false": "The customer is asking a question or requesting a feature."
124
+ }
125
+ },
126
+ "team": {
127
+ "type": "choice",
128
+ "instructions": "Which team should own this ticket?",
129
+ "criteria": {
130
+ "payments": "Checkout, billing, or payment processing issues.",
131
+ "frontend": "Rendering, layout, or browser compatibility issues.",
132
+ "account": "Login, permissions, or profile issues."
133
+ }
134
+ },
135
+ "urgency": {
136
+ "type": "score",
137
+ "instructions": "How urgent is this ticket?",
138
+ "criteria": [
139
+ "Can wait for the next release",
140
+ "Should be fixed this week",
141
+ "Blocking revenue right now"
142
+ ]
143
+ }
144
+ }
145
+ }
146
+ ```
147
+
148
+ **Example curl:**
149
+
150
+ ```bash
151
+ curl -s -X POST http://localhost:8080/v1/systemone \
152
+ -H "Content-Type: application/json" \
153
+ -d @- <<'EOF'
154
+ {
155
+ "state": {"customer_tier": "enterprise", "ticket": "Blank screen after Pay click."},
156
+ "questions": {
157
+ "is_bug": {"type": "noul", "instructions": "Software defect?",
158
+ "criteria": {"true": "Unexpected behavior.", "false": "Feature request."}}
159
+ }
160
+ }
161
+ EOF
162
+ ```
163
+
164
+ **Response shape:**
165
+
166
+ ```json
167
+ {
168
+ "model": "jev-1.13",
169
+ "usage": {"input_tokens": 42, "output_tokens": null},
170
+ "answers": {
171
+ "is_bug": {"type": "noul", "noul": 0.97},
172
+ "team": {"type": "choice", "choice": "frontend", "confidence": 0.91,
173
+ "probabilities": {"payments": 0.05, "frontend": 0.91, "account": 0.04}},
174
+ "urgency":{"type": "score", "score": 1.7, "confidence": 0.85,
175
+ "legend": {"0": "Can wait", "1": "This week", "2": "Blocking revenue"},
176
+ "probabilities": {"0": 0.05, "1": 0.50, "2": 0.45}}
177
+ }
178
+ }
179
+ ```
180
+
181
+ **Answer types:**
182
+
183
+ | Type | Key fields |
184
+ |---|---|
185
+ | `noul` | `noul` (float 0–1, probability of true/yes) |
186
+ | `choice` | `choice` (string), `confidence`, `probabilities` |
187
+ | `score` | `score` (float), `confidence`, `legend`, `probabilities` |
188
+
189
+ **HTTP status codes:**
190
+
191
+ | Code | Meaning |
192
+ |---|---|
193
+ | 200 | Success |
194
+ | 422 | Invalid request body (missing `state` or `questions`, wrong types) |
195
+ | 413 | Request body exceeds 64 KB limit |
196
+ | 502 | Upstream OpenRouter / Jev error |
197
+ | 503 | Proxy not ready (SDK client unavailable) |
198
+
199
+ ---
200
+
201
+ ## Running tests
202
+
203
+ ```bash
204
+ # Unit tests only (no OpenRouter calls, no credits spent)
205
+ pytest tests/test_server.py -v -m "not integration"
206
+
207
+ # Integration test (real inference — costs credits)
208
+ OPENROUTER_API_KEY=sk-or-v1-... pytest tests/test_server.py -m integration -v
209
+ ```
210
+
211
+ ---
212
+
213
+ ## Starting the server locally
214
+
215
+ ```bash
216
+ OPENROUTER_API_KEY=sk-or-v1-... uvicorn server:app --host 0.0.0.0 --port 8080 --reload
217
+ ```
218
+
219
+ Or load from `.env`:
220
+
221
+ ```bash
222
+ # With python-dotenv pre-load via a helper
223
+ export $(grep -v '^#' .env | xargs) && uvicorn server:app --port 8080
224
+ ```
225
+
226
+ ---
227
+
228
+ ## Building the Docker image
229
+
230
+ ```bash
231
+ # Build
232
+ docker build -t jev-proxy:1.0.0 .
233
+
234
+ # Tag for GHCR
235
+ docker tag jev-proxy:1.0.0 ghcr.io/i780801/jev-proxy:1.0.0
236
+
237
+ # Smoke-test the container locally (host port 8081 if 8080 is in use)
238
+ docker run --rm -e OPENROUTER_API_KEY=sk-or-v1-... -p 8081:8080 jev-proxy:1.0.0
239
+
240
+ # Authenticate to GHCR (PAT needs write:packages scope)
241
+ echo "<your-ghcr-pat>" | docker login ghcr.io -u i780801 --password-stdin
242
+
243
+ # Push
244
+ docker push ghcr.io/i780801/jev-proxy:1.0.0
245
+ ```
246
+
247
+ **Image pushed:** `ghcr.io/i780801/jev-proxy:1.0.0`
248
+ Digest: `sha256:d2e475d5f8803369f909b0a714b8d610084565de9e3d97a7fe6031114bc5db48`
249
+ Visibility: private (GHCR default — readable by SAP AI Core once the pull secret is registered)
250
+
251
+ ---
252
+
253
+ ## SAP AI Core deployment
254
+
255
+ > **These steps require your explicit confirmation before execution.**
256
+ > No commands below have been run automatically.
257
+
258
+ ### 1. Obtain your SAP AI Core bearer token
259
+
260
+ ```bash
261
+ curl -s -X POST "$AICORE_AUTH_URL/oauth/token" \
262
+ -H "Content-Type: application/x-www-form-urlencoded" \
263
+ -d "grant_type=client_credentials" \
264
+ -d "client_id=$AICORE_CLIENT_ID" \
265
+ -d "client_secret=$AICORE_CLIENT_SECRET" \
266
+ | jq -r .access_token
267
+ ```
268
+
269
+ Store the result as `AICORE_TOKEN`.
270
+
271
+ ### 2. Create the OpenRouter Generic Secret
272
+
273
+ Base64-encode your key (no trailing newline):
274
+
275
+ ```bash
276
+ echo -n "sk-or-v1-YOUR_KEY" | base64
277
+ ```
278
+
279
+ Then create the secret:
280
+
281
+ ```bash
282
+ curl -s -X POST "$AICORE_BASE_URL/v2/admin/secrets" \
283
+ -H "Authorization: Bearer $AICORE_TOKEN" \
284
+ -H "AI-Resource-Group: $AICORE_RG" \
285
+ -H "Content-Type: application/json" \
286
+ -d '{
287
+ "name": "openrouter-key",
288
+ "data": {
289
+ "OPENROUTER_API_KEY": "<base64-encoded key>"
290
+ }
291
+ }'
292
+ ```
293
+
294
+ ### 3. Register the GHCR pull secret in SAP AI Core
295
+
296
+ The image is at `ghcr.io/i780801/jev-proxy:1.0.0` (private). SAP AI Core needs a pull secret to fetch it.
297
+
298
+ You need a GitHub PAT with **`read:packages`** scope (a separate, narrower token from the push token).
299
+
300
+ ```bash
301
+ curl -s -X POST "$AICORE_BASE_URL/v2/admin/dockerRegistrySecrets" \
302
+ -H "Authorization: Bearer $AICORE_TOKEN" \
303
+ -H "Content-Type: application/json" \
304
+ -d '{
305
+ "name": "ghcr-secret",
306
+ "data": {
307
+ "ghcr.io": {
308
+ "username": "i780801",
309
+ "password": "<GitHub PAT with read:packages scope>"
310
+ }
311
+ }
312
+ }'
313
+ ```
314
+
315
+ ### 4. Register a Git repository and sync the ServingTemplate
316
+
317
+ ```bash
318
+ # Register your repo
319
+ curl -s -X POST "$AICORE_BASE_URL/v2/admin/repositories" \
320
+ -H "Authorization: Bearer $AICORE_TOKEN" \
321
+ -H "Content-Type: application/json" \
322
+ -d '{
323
+ "name": "jev-aicore-proxy",
324
+ "url": "'"$GIT_REPO_URL"'",
325
+ "username": "<git-user>",
326
+ "password": "<git-token>"
327
+ }'
328
+
329
+ # Create an Application (GitOps sync)
330
+ curl -s -X POST "$AICORE_BASE_URL/v2/admin/applications" \
331
+ -H "Authorization: Bearer $AICORE_TOKEN" \
332
+ -H "Content-Type: application/json" \
333
+ -d '{
334
+ "applicationName": "jev-proxy-app",
335
+ "repositoryUrl": "'"$GIT_REPO_URL"'",
336
+ "revision": "HEAD",
337
+ "path": "deployment"
338
+ }'
339
+ ```
340
+
341
+ Wait for the sync; verify the scenario and executable are visible:
342
+
343
+ ```bash
344
+ curl -s "$AICORE_BASE_URL/v2/lm/scenarios" \
345
+ -H "Authorization: Bearer $AICORE_TOKEN" \
346
+ -H "AI-Resource-Group: $AICORE_RG" | jq
347
+ ```
348
+
349
+ ### 5. Create a configuration and start the deployment
350
+
351
+ ```bash
352
+ # Create configuration
353
+ CONFIG_ID=$(curl -s -X POST "$AICORE_BASE_URL/v2/lm/configurations" \
354
+ -H "Authorization: Bearer $AICORE_TOKEN" \
355
+ -H "AI-Resource-Group: $AICORE_RG" \
356
+ -H "Content-Type: application/json" \
357
+ -d '{
358
+ "name": "jev-proxy-config",
359
+ "executableId": "jev-proxy-serving",
360
+ "scenarioId": "jev-proxy",
361
+ "versionId": "1.0.0",
362
+ "parameterBindings": [],
363
+ "inputArtifactBindings": []
364
+ }' | jq -r .id)
365
+
366
+ echo "Config ID: $CONFIG_ID"
367
+
368
+ # Create the deployment
369
+ DEPLOYMENT_ID=$(curl -s -X POST "$AICORE_BASE_URL/v2/lm/deployments" \
370
+ -H "Authorization: Bearer $AICORE_TOKEN" \
371
+ -H "AI-Resource-Group: $AICORE_RG" \
372
+ -H "Content-Type: application/json" \
373
+ -d '{"configurationId": "'"$CONFIG_ID"'"}' | jq -r .id)
374
+
375
+ echo "Deployment ID: $DEPLOYMENT_ID"
376
+ ```
377
+
378
+ ### 6. Wait for RUNNING status and get the inference URL
379
+
380
+ ```bash
381
+ curl -s "$AICORE_BASE_URL/v2/lm/deployments/$DEPLOYMENT_ID" \
382
+ -H "Authorization: Bearer $AICORE_TOKEN" \
383
+ -H "AI-Resource-Group: $AICORE_RG" | jq '{status: .status, deploymentUrl: .deploymentUrl}'
384
+ ```
385
+
386
+ Wait until `status` is `RUNNING`.
387
+
388
+ ### 7. Call the deployed endpoint
389
+
390
+ ```bash
391
+ INFERENCE_URL="<deploymentUrl from step 6>"
392
+
393
+ # Health check
394
+ curl -s "$INFERENCE_URL/v2/health" \
395
+ -H "Authorization: Bearer $AICORE_TOKEN" \
396
+ -H "AI-Resource-Group: $AICORE_RG"
397
+
398
+ # System One inference
399
+ curl -s -X POST "$INFERENCE_URL/v1/systemone" \
400
+ -H "Authorization: Bearer $AICORE_TOKEN" \
401
+ -H "AI-Resource-Group: $AICORE_RG" \
402
+ -H "Content-Type: application/json" \
403
+ -d '{
404
+ "state": {"customer_tier": "enterprise", "ticket": "Blank screen after Pay click."},
405
+ "questions": {
406
+ "is_bug": {"type": "noul", "instructions": "Software defect?",
407
+ "criteria": {"true": "Unexpected behavior.", "false": "Feature request."}}
408
+ }
409
+ }'
410
+ ```
411
+
412
+ ---
413
+
414
+ ## Known limitations and troubleshooting
415
+
416
+ | Symptom | Cause / Fix |
417
+ |---|---|
418
+ | Container exits immediately | `OPENROUTER_API_KEY` is missing — check secret injection |
419
+ | `POST /v1/systemone` → 502 | OpenRouter returned an error; check logs with `kubectl logs` or AI Core log API |
420
+ | `422 Unprocessable Entity` | Request body missing `state` or `questions`, or wrong JSON types |
421
+ | ServingTemplate not found | Git sync may still be pending — check Application status |
422
+ | `401` from SAP AI Core | Bearer token expired — re-obtain with `oauth/token` |
423
+ | Image pull failure | Docker registry secret not registered, or wrong name in YAML |
424
+
425
+ ---
426
+
427
+ ## Cost considerations
428
+
429
+ - **OpenRouter / Jev:** Each `POST /v1/systemone` call consumes Jev credits through OpenRouter. Unit tests mock the SDK and spend no credits. Only integration tests (`-m integration`) make real calls.
430
+ - **SAP AI Core:** Deployed containers accrue compute charges even when idle (running but receiving no traffic). Stop the deployment when not needed:
431
+ ```bash
432
+ curl -s -X PATCH "$AICORE_BASE_URL/v2/lm/deployments/$DEPLOYMENT_ID" \
433
+ -H "Authorization: Bearer $AICORE_TOKEN" \
434
+ -H "AI-Resource-Group: $AICORE_RG" \
435
+ -H "Content-Type: application/json" \
436
+ -d '{"targetStatus": "STOPPED"}'
437
+ ```
438
+
439
+ ---
440
+
441
+ ## PoC scope and limitations
442
+
443
+ - This is a testing PoC, not a production service.
444
+ - No fallback model, no authentication on the internal container, no database, no queue.
445
+ - The proxy is stateless. Horizontal scaling is possible but not configured here.
446
+ - SAP AI Core deployment status is **not yet verified end-to-end** — the deployment YAML is prepared with correct schema based on published documentation, but an actual deployment and live inference test has not been executed.
@@ -0,0 +1,73 @@
1
+ import json
2
+ import urllib.parse
3
+ import urllib.request
4
+ from pathlib import Path
5
+
6
+
7
+ def _load_env() -> dict:
8
+ env = {}
9
+ for p in [Path.cwd() / ".env", Path(__file__).parent.parent / ".env"]:
10
+ if p.exists():
11
+ for line in p.read_text().splitlines():
12
+ line = line.strip()
13
+ if line and not line.startswith("#") and "=" in line:
14
+ k, _, v = line.partition("=")
15
+ env.setdefault(k.strip(), v.strip().strip('"'))
16
+ return env
17
+
18
+
19
+ def _get_token(auth_url: str, client_id: str, client_secret: str) -> str:
20
+ data = urllib.parse.urlencode({
21
+ "grant_type": "client_credentials",
22
+ "client_id": client_id,
23
+ "client_secret": client_secret,
24
+ }).encode()
25
+ with urllib.request.urlopen(
26
+ urllib.request.Request(f"{auth_url}/oauth/token", data=data, method="POST")
27
+ ) as r:
28
+ return json.loads(r.read())["access_token"]
29
+
30
+
31
+ def call_jev(state: dict, questions: dict) -> dict:
32
+ """
33
+ Call Jev via the SAP AI Core proxy.
34
+
35
+ Reads credentials from a .env file in the working directory:
36
+ AICORE_CLIENT_ID
37
+ AICORE_CLIENT_SECRET
38
+ AICORE_AUTH_URL
39
+ AICORE_BASE_URL
40
+ AICORE_RESOURCE_GROUP (optional, defaults to "default")
41
+
42
+ Args:
43
+ state: Decision context as a dict.
44
+ questions: Mapping of question name to question definition.
45
+ Each definition is a dict with keys:
46
+ type – "noul" | "choice" | "score"
47
+ instructions – plain-text question
48
+ criteria – dict (noul/choice) or list (score)
49
+
50
+ Returns:
51
+ Raw JSON response from Jev as a dict:
52
+ model – model identifier string
53
+ usage – input_tokens, output_tokens
54
+ answers – dict of question name → answer object
55
+ """
56
+ env = _load_env()
57
+ missing = [k for k in ("AICORE_CLIENT_ID", "AICORE_CLIENT_SECRET", "AICORE_AUTH_URL", "AICORE_BASE_URL") if k not in env]
58
+ if missing:
59
+ raise EnvironmentError(f"Missing required .env keys: {', '.join(missing)}")
60
+
61
+ token = _get_token(env["AICORE_AUTH_URL"], env["AICORE_CLIENT_ID"], env["AICORE_CLIENT_SECRET"])
62
+ req = urllib.request.Request(
63
+ f"{env['AICORE_BASE_URL']}/v2/inference/deployments/d00448831cb3408b/v1/systemone",
64
+ data=json.dumps({"state": state, "questions": questions}).encode(),
65
+ method="POST",
66
+ headers={
67
+ "Authorization": f"Bearer {token}",
68
+ "AI-Resource-Group": env.get("AICORE_RESOURCE_GROUP", "default"),
69
+ "Content-Type": "application/json",
70
+ },
71
+ )
72
+ with urllib.request.urlopen(req) as r:
73
+ return json.loads(r.read())
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: jev-aicore
3
+ Version: 1.0.0
4
+ Summary: Call Jev via SAP AI Core — one function, zero setup
5
+ Requires-Python: >=3.10
@@ -0,0 +1,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ jev_aicore/__init__.py
4
+ jev_aicore.egg-info/PKG-INFO
5
+ jev_aicore.egg-info/SOURCES.txt
6
+ jev_aicore.egg-info/dependency_links.txt
7
+ jev_aicore.egg-info/top_level.txt
8
+ tests/test_server.py
9
+ tests/testing.py
@@ -0,0 +1 @@
1
+ jev_aicore
@@ -0,0 +1,13 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "jev-aicore"
7
+ version = "1.0.0"
8
+ description = "Call Jev via SAP AI Core — one function, zero setup"
9
+ requires-python = ">=3.10"
10
+ dependencies = []
11
+
12
+ [tool.setuptools.packages.find]
13
+ include = ["jev_aicore*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,334 @@
1
+ """
2
+ Unit tests for the FastAPI proxy. The TypeSafeClient is mocked throughout
3
+ so no real OpenRouter credits are spent.
4
+
5
+ Run unit tests:
6
+ pytest tests/test_server.py -v -m "not integration"
7
+
8
+ Run integration tests (real OpenRouter calls, costs credits):
9
+ OPENROUTER_API_KEY=sk-or-... pytest tests/test_server.py -m integration -v
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from unittest.mock import MagicMock, patch
16
+
17
+ import pytest
18
+ from fastapi.testclient import TestClient
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Helpers: build mock SDK return values using the real Pydantic models
22
+ # ---------------------------------------------------------------------------
23
+
24
+ def _make_mock_result():
25
+ """Build a realistic SystemOneResponse using real SDK Pydantic types."""
26
+ from typesafe_sdk import ChoiceAnswer, NoulAnswer, ScoreAnswer, SystemOneResponse, Usage
27
+
28
+ return SystemOneResponse(
29
+ model="jev-1.13",
30
+ usage=Usage(input_tokens=42, output_tokens=None),
31
+ answers={
32
+ "is_bug": NoulAnswer(type="noul", noul=0.97),
33
+ "team": ChoiceAnswer(
34
+ type="choice",
35
+ choice="frontend",
36
+ confidence=0.91,
37
+ probabilities={"payments": 0.05, "frontend": 0.91, "account": 0.04},
38
+ ),
39
+ "urgency": ScoreAnswer(
40
+ type="score",
41
+ score=1.7,
42
+ confidence=0.85,
43
+ legend={0: "Can wait", 1: "This week", 2: "Blocking revenue"},
44
+ probabilities={0: 0.05, 1: 0.50, 2: 0.45},
45
+ ),
46
+ },
47
+ )
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Fixtures
52
+ # ---------------------------------------------------------------------------
53
+
54
+ @pytest.fixture()
55
+ def client_with_mock_sdk():
56
+ """TestClient whose lifespan installs a mock SDK client instead of a real one."""
57
+ mock_sdk = MagicMock()
58
+ mock_sdk.system_one.return_value = _make_mock_result()
59
+
60
+ # Patch _init_client so the lifespan receives the mock, not a real client.
61
+ with patch("server._init_client", return_value=mock_sdk):
62
+ import server
63
+ with TestClient(server.app, raise_server_exceptions=False) as c:
64
+ yield c, mock_sdk
65
+
66
+
67
+ @pytest.fixture()
68
+ def client_no_key():
69
+ """TestClient whose lifespan raises RuntimeError (missing key)."""
70
+ with patch("server._init_client", side_effect=RuntimeError("OPENROUTER_API_KEY")):
71
+ import server
72
+ # TestClient will raise during lifespan; we catch that.
73
+ with pytest.raises(RuntimeError):
74
+ with TestClient(server.app, raise_server_exceptions=True):
75
+ pass
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Three-question reference payload (mirrors the working direct SDK example)
80
+ # ---------------------------------------------------------------------------
81
+
82
+ THREE_QUESTION_PAYLOAD = {
83
+ "state": {
84
+ "customer_tier": "enterprise",
85
+ "ticket": "My checkout page shows a blank screen after I click Pay.",
86
+ },
87
+ "questions": {
88
+ "is_bug": {
89
+ "type": "noul",
90
+ "instructions": "Is the customer reporting a software defect?",
91
+ "criteria": {
92
+ "true": "The customer describes broken or unexpected product behavior.",
93
+ "false": "The customer is asking a question or requesting a feature.",
94
+ },
95
+ },
96
+ "team": {
97
+ "type": "choice",
98
+ "instructions": "Which team should own this ticket?",
99
+ "criteria": {
100
+ "payments": "Checkout, billing, or payment processing issues.",
101
+ "frontend": "Rendering, layout, or browser compatibility issues.",
102
+ "account": "Login, permissions, or profile issues.",
103
+ },
104
+ },
105
+ "urgency": {
106
+ "type": "score",
107
+ "instructions": "How urgent is this ticket?",
108
+ "criteria": [
109
+ "Can wait for the next release",
110
+ "Should be fixed this week",
111
+ "Blocking revenue right now",
112
+ ],
113
+ },
114
+ },
115
+ }
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Health endpoint
120
+ # ---------------------------------------------------------------------------
121
+
122
+ class TestHealth:
123
+ def test_returns_200(self, client_with_mock_sdk):
124
+ client, _ = client_with_mock_sdk
125
+ resp = client.get("/v2/health")
126
+ assert resp.status_code == 200
127
+
128
+ def test_body(self, client_with_mock_sdk):
129
+ client, _ = client_with_mock_sdk
130
+ resp = client.get("/v2/health")
131
+ assert resp.json() == {"status": "ok"}
132
+
133
+ def test_does_not_call_sdk(self, client_with_mock_sdk):
134
+ client, mock_sdk = client_with_mock_sdk
135
+ client.get("/v2/health")
136
+ mock_sdk.system_one.assert_not_called()
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # /v1/systemone — happy path
141
+ # ---------------------------------------------------------------------------
142
+
143
+ class TestSystemOne:
144
+ def test_happy_path_status(self, client_with_mock_sdk):
145
+ client, _ = client_with_mock_sdk
146
+ resp = client.post("/v1/systemone", json=THREE_QUESTION_PAYLOAD)
147
+ assert resp.status_code == 200
148
+
149
+ def test_happy_path_structure(self, client_with_mock_sdk):
150
+ client, _ = client_with_mock_sdk
151
+ body = client.post("/v1/systemone", json=THREE_QUESTION_PAYLOAD).json()
152
+ assert "answers" in body
153
+ assert "model" in body
154
+ assert "usage" in body
155
+
156
+ def test_answer_types_present(self, client_with_mock_sdk):
157
+ client, _ = client_with_mock_sdk
158
+ answers = client.post("/v1/systemone", json=THREE_QUESTION_PAYLOAD).json()["answers"]
159
+ assert "is_bug" in answers
160
+ assert "team" in answers
161
+ assert "urgency" in answers
162
+
163
+ def test_noul_answer_shape(self, client_with_mock_sdk):
164
+ client, _ = client_with_mock_sdk
165
+ answers = client.post("/v1/systemone", json=THREE_QUESTION_PAYLOAD).json()["answers"]
166
+ assert answers["is_bug"]["type"] == "noul"
167
+ assert "noul" in answers["is_bug"]
168
+
169
+ def test_choice_answer_shape(self, client_with_mock_sdk):
170
+ client, _ = client_with_mock_sdk
171
+ answers = client.post("/v1/systemone", json=THREE_QUESTION_PAYLOAD).json()["answers"]
172
+ assert answers["team"]["type"] == "choice"
173
+ assert answers["team"]["choice"] == "frontend"
174
+ assert "probabilities" in answers["team"]
175
+
176
+ def test_score_answer_shape(self, client_with_mock_sdk):
177
+ client, _ = client_with_mock_sdk
178
+ answers = client.post("/v1/systemone", json=THREE_QUESTION_PAYLOAD).json()["answers"]
179
+ assert answers["urgency"]["type"] == "score"
180
+ assert "score" in answers["urgency"]
181
+ assert "legend" in answers["urgency"]
182
+
183
+ def test_sdk_called_with_correct_args(self, client_with_mock_sdk):
184
+ client, mock_sdk = client_with_mock_sdk
185
+ client.post("/v1/systemone", json=THREE_QUESTION_PAYLOAD)
186
+ mock_sdk.system_one.assert_called_once_with(
187
+ model="jev-1.13",
188
+ state=THREE_QUESTION_PAYLOAD["state"],
189
+ questions=THREE_QUESTION_PAYLOAD["questions"],
190
+ )
191
+
192
+ def test_usage_returned(self, client_with_mock_sdk):
193
+ client, _ = client_with_mock_sdk
194
+ body = client.post("/v1/systemone", json=THREE_QUESTION_PAYLOAD).json()
195
+ assert body["usage"]["input_tokens"] == 42
196
+
197
+ def test_model_field_returned(self, client_with_mock_sdk):
198
+ client, _ = client_with_mock_sdk
199
+ body = client.post("/v1/systemone", json=THREE_QUESTION_PAYLOAD).json()
200
+ assert body["model"] == "jev-1.13"
201
+
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # Validation errors
205
+ # ---------------------------------------------------------------------------
206
+
207
+ class TestValidation:
208
+ def test_missing_state(self, client_with_mock_sdk):
209
+ client, _ = client_with_mock_sdk
210
+ resp = client.post("/v1/systemone", json={"questions": {}})
211
+ assert resp.status_code == 422
212
+
213
+ def test_missing_questions(self, client_with_mock_sdk):
214
+ client, _ = client_with_mock_sdk
215
+ resp = client.post("/v1/systemone", json={"state": {}})
216
+ assert resp.status_code == 422
217
+
218
+ def test_empty_body(self, client_with_mock_sdk):
219
+ client, _ = client_with_mock_sdk
220
+ resp = client.post("/v1/systemone", json={})
221
+ assert resp.status_code == 422
222
+
223
+ def test_non_dict_state(self, client_with_mock_sdk):
224
+ client, _ = client_with_mock_sdk
225
+ resp = client.post("/v1/systemone", json={"state": "bad", "questions": {}})
226
+ assert resp.status_code == 422
227
+
228
+ def test_api_key_not_in_validation_error(self, client_with_mock_sdk):
229
+ client, _ = client_with_mock_sdk
230
+ resp = client.post("/v1/systemone", json={})
231
+ body_text = resp.text
232
+ assert "sk-or" not in body_text
233
+ assert "OPENROUTER" not in body_text
234
+
235
+
236
+ # ---------------------------------------------------------------------------
237
+ # Upstream failure handling
238
+ # ---------------------------------------------------------------------------
239
+
240
+ class TestUpstreamFailure:
241
+ def test_sdk_exception_returns_502(self, client_with_mock_sdk):
242
+ client, mock_sdk = client_with_mock_sdk
243
+ mock_sdk.system_one.side_effect = RuntimeError("upstream exploded")
244
+ resp = client.post("/v1/systemone", json=THREE_QUESTION_PAYLOAD)
245
+ assert resp.status_code == 502
246
+
247
+ def test_sdk_exception_body_safe(self, client_with_mock_sdk):
248
+ client, mock_sdk = client_with_mock_sdk
249
+ mock_sdk.system_one.side_effect = RuntimeError("secret_key_abc123")
250
+ resp = client.post("/v1/systemone", json=THREE_QUESTION_PAYLOAD)
251
+ assert "secret_key_abc123" not in resp.text
252
+
253
+ def test_client_unavailable_returns_503(self):
254
+ """When _jev_client is None at request time, return 503."""
255
+ mock_sdk = MagicMock()
256
+ mock_sdk.system_one.return_value = _make_mock_result()
257
+ with patch("server._init_client", return_value=mock_sdk):
258
+ import server
259
+ with TestClient(server.app, raise_server_exceptions=False) as client:
260
+ # Simulate post-startup client teardown
261
+ import server as srv
262
+ srv._jev_client = None
263
+ resp = client.post("/v1/systemone", json=THREE_QUESTION_PAYLOAD)
264
+ assert resp.status_code == 503
265
+
266
+
267
+ # ---------------------------------------------------------------------------
268
+ # Startup: missing API key
269
+ # ---------------------------------------------------------------------------
270
+
271
+ class TestStartup:
272
+ def test_missing_key_raises_at_startup(self):
273
+ """_init_client() must raise RuntimeError when no key is set."""
274
+ import server
275
+ original = os.environ.pop("OPENROUTER_API_KEY", None)
276
+ try:
277
+ with pytest.raises(RuntimeError, match="OPENROUTER_API_KEY"):
278
+ server._init_client()
279
+ finally:
280
+ if original is not None:
281
+ os.environ["OPENROUTER_API_KEY"] = original
282
+
283
+ def test_key_not_in_logs(self, caplog):
284
+ """The real key must never appear in log output."""
285
+ import logging
286
+ import server
287
+ mock_sdk = MagicMock()
288
+ mock_sdk.system_one.return_value = _make_mock_result()
289
+ with patch("server._init_client", return_value=mock_sdk):
290
+ with caplog.at_level(logging.DEBUG):
291
+ with TestClient(server.app, raise_server_exceptions=False) as c:
292
+ c.get("/v2/health")
293
+ assert "sk-or" not in caplog.text
294
+ assert "OPENROUTER_API_KEY" not in caplog.text
295
+
296
+
297
+ # ---------------------------------------------------------------------------
298
+ # Integration test (real inference — requires OPENROUTER_API_KEY)
299
+ # ---------------------------------------------------------------------------
300
+
301
+ @pytest.mark.integration
302
+ class TestIntegration:
303
+ """
304
+ These tests hit real OpenRouter and spend credits.
305
+ Only run when OPENROUTER_API_KEY is set and -m integration is passed.
306
+
307
+ OPENROUTER_API_KEY=sk-or-... pytest tests/test_server.py -m integration -v
308
+ """
309
+
310
+ def test_real_health(self):
311
+ if not os.environ.get("OPENROUTER_API_KEY", ""):
312
+ pytest.skip("OPENROUTER_API_KEY not set")
313
+ import server
314
+ with TestClient(server.app) as c:
315
+ resp = c.get("/v2/health")
316
+ assert resp.status_code == 200
317
+
318
+ def test_real_inference(self):
319
+ if not os.environ.get("OPENROUTER_API_KEY", ""):
320
+ pytest.skip("OPENROUTER_API_KEY not set")
321
+ import server
322
+ with TestClient(server.app) as c:
323
+ resp = c.post("/v1/systemone", json=THREE_QUESTION_PAYLOAD)
324
+ assert resp.status_code == 200
325
+ body = resp.json()
326
+ assert "answers" in body
327
+ answers = body["answers"]
328
+ assert "is_bug" in answers
329
+ assert "team" in answers
330
+ assert "urgency" in answers
331
+ # Verify answer shapes from real Jev response
332
+ assert answers["is_bug"]["type"] == "noul"
333
+ assert answers["team"]["type"] == "choice"
334
+ assert answers["urgency"]["type"] == "score"
@@ -0,0 +1,39 @@
1
+ from jev_aicore import call_jev
2
+ import json
3
+
4
+ result = call_jev(
5
+ state={
6
+ "customer_tier": "enterprise",
7
+ "ticket": "My checkout page shows a blank screen after I click Pay. I have tried two browsers.",
8
+ },
9
+ questions={
10
+ "is_bug": {
11
+ "type": "noul",
12
+ "instructions": "Is the customer reporting a software defect?",
13
+ "criteria": {
14
+ "true": "The customer describes broken or unexpected product behavior.",
15
+ "false": "The customer is asking a question or requesting a feature.",
16
+ },
17
+ },
18
+ "team": {
19
+ "type": "choice",
20
+ "instructions": "Which team should own this ticket?",
21
+ "criteria": {
22
+ "payments": "Checkout, billing, or payment processing issues.",
23
+ "frontend": "Rendering, layout, or browser compatibility issues.",
24
+ "account": "Login, permissions, or profile issues.",
25
+ },
26
+ },
27
+ "urgency": {
28
+ "type": "score",
29
+ "instructions": "How urgent is this ticket?",
30
+ "criteria": [
31
+ "Can wait for the next release",
32
+ "Should be fixed this week",
33
+ "Blocking revenue right now",
34
+ ],
35
+ },
36
+ },
37
+ )
38
+
39
+ print(json.dumps(result, indent=2))