A3-R5C benchmark-only GLM adapter deployment

#104
benchmarks/a3_provider_benchmark.py ADDED
@@ -0,0 +1,514 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Standalone A3 B1/B2 provider benchmark runner.
3
+
4
+ This module is intentionally unreachable from SmartPaper app/task execution.
5
+ It evaluates fixed synthetic cases, records provider-neutral usage telemetry,
6
+ and never touches Worker/D1 quota, payment, order, or reservation state.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ from dataclasses import asdict
12
+ from datetime import datetime, timezone
13
+ import hashlib
14
+ import json
15
+ import os
16
+ from pathlib import Path
17
+ import re
18
+ import sys
19
+ from typing import Any, Mapping
20
+ from zoneinfo import ZoneInfo
21
+
22
+ HF = Path(__file__).resolve().parents[1]
23
+ if str(HF) not in sys.path:
24
+ sys.path.insert(0, str(HF))
25
+
26
+ from core.model_usage import JsonlModelUsageSink, ModelUsageEvent # noqa: E402
27
+ from core.pipeline_agent import SSciPipelineAgent # noqa: E402
28
+ from core.provider_adapters.gemini_rest_adapter import GeminiRestAdapter # noqa: E402
29
+ from core.provider_adapters.glm_rest_adapter import ZhipuGLMRestAdapter # noqa: E402
30
+
31
+ DEFAULT_FIXTURE = Path(__file__).resolve().parent / "fixtures" / "a3_benchmark_cases.json"
32
+
33
+
34
+ class MemoryUsageSink:
35
+ def __init__(self, downstream: Any = None):
36
+ self.events: list[ModelUsageEvent] = []
37
+ self.downstream = downstream
38
+
39
+ def emit(self, event: ModelUsageEvent) -> None:
40
+ self.events.append(event)
41
+ if self.downstream is not None:
42
+ self.downstream.emit(event)
43
+
44
+
45
+ def load_fixture(path: str | Path = DEFAULT_FIXTURE) -> dict[str, Any]:
46
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
47
+ if not isinstance(data.get("profiles"), dict) or not isinstance(data.get("cases"), list):
48
+ raise ValueError("invalid A3 benchmark fixture")
49
+ return data
50
+
51
+
52
+ def _english_word_count(text: str) -> int:
53
+ return len(re.findall(r"\b[A-Za-z]+(?:[-'][A-Za-z]+)*\b", str(text or "")))
54
+
55
+
56
+ def _script_ratio(text: str, script: str) -> float:
57
+ letters = [ch for ch in str(text or "") if ch.isalpha()]
58
+ if not letters:
59
+ return 0.0
60
+ name = str(script or "").strip().lower()
61
+ if name == "han":
62
+ matched = sum(1 for ch in letters if "\u4e00" <= ch <= "\u9fff")
63
+ elif name == "cyrillic":
64
+ matched = sum(1 for ch in letters if "\u0400" <= ch <= "\u052f")
65
+ else:
66
+ raise ValueError(f"unsupported script gate: {script}")
67
+ return matched / len(letters)
68
+
69
+
70
+ def evaluate_gate(output: str, gate: Mapping[str, Any]) -> dict[str, Any]:
71
+ kind = str(gate.get("type") or "").strip().lower()
72
+ text = str(output or "")
73
+ lower = text.lower()
74
+ passed = False
75
+ detail = ""
76
+
77
+ if kind == "exact_ci":
78
+ expected = str(gate.get("expected") or "").strip().lower()
79
+ observed = text.strip().lower()
80
+ passed = observed == expected
81
+ detail = f"expected={expected!r} observed={observed[:120]!r}"
82
+ elif kind == "contains_all_ci":
83
+ values = [str(v) for v in gate.get("values") or []]
84
+ missing = [v for v in values if v.lower() not in lower]
85
+ passed = not missing
86
+ detail = f"missing={missing}"
87
+ elif kind == "contains_any_ci":
88
+ values = [str(v) for v in gate.get("values") or []]
89
+ passed = any(v.lower() in lower for v in values)
90
+ detail = f"values={values}"
91
+ elif kind == "forbid_any_ci":
92
+ values = [str(v) for v in gate.get("values") or []]
93
+ found = [v for v in values if v.lower() in lower]
94
+ passed = not found
95
+ detail = f"found={found}"
96
+ elif kind == "count_exact_ci":
97
+ value = str(gate.get("value") or "")
98
+ expected = int(gate.get("count") or 0)
99
+ observed = lower.count(value.lower())
100
+ passed = observed == expected
101
+ detail = f"value={value!r} expected={expected} observed={observed}"
102
+ elif kind == "word_range":
103
+ count = _english_word_count(text)
104
+ minimum = int(gate.get("min") or 0)
105
+ maximum = int(gate.get("max") or 10**9)
106
+ passed = minimum <= count <= maximum
107
+ detail = f"words={count} range=[{minimum},{maximum}]"
108
+ elif kind == "script_min_ratio":
109
+ ratio = _script_ratio(text, str(gate.get("script") or ""))
110
+ minimum = float(gate.get("min") or 0.0)
111
+ passed = ratio >= minimum
112
+ detail = f"ratio={ratio:.4f} minimum={minimum:.4f}"
113
+ elif kind in {"json_keys_exact", "json_value_ci"}:
114
+ try:
115
+ parsed = json.loads(text)
116
+ except Exception as exc:
117
+ parsed = None
118
+ detail = f"json_error={exc.__class__.__name__}"
119
+ if isinstance(parsed, dict):
120
+ if kind == "json_keys_exact":
121
+ expected = {str(v) for v in gate.get("keys") or []}
122
+ observed = set(map(str, parsed.keys()))
123
+ passed = observed == expected
124
+ detail = f"expected={sorted(expected)} observed={sorted(observed)}"
125
+ else:
126
+ key = str(gate.get("key") or "")
127
+ expected = str(gate.get("expected") or "").strip().lower()
128
+ observed = str(parsed.get(key) or "").strip().lower()
129
+ passed = observed == expected
130
+ detail = f"key={key!r} expected={expected!r} observed={observed!r}"
131
+ else:
132
+ raise ValueError(f"unsupported benchmark gate: {kind}")
133
+
134
+ return {"type": kind, "passed": bool(passed), "detail": detail}
135
+
136
+
137
+ def evaluate_case(output: str, case: Mapping[str, Any]) -> dict[str, Any]:
138
+ gate_results = [evaluate_gate(output, gate) for gate in list(case.get("gates") or [])]
139
+ passed = all(row["passed"] for row in gate_results)
140
+ return {
141
+ "passed": passed,
142
+ "critical": bool(case.get("critical", True)),
143
+ "gate_results": gate_results,
144
+ }
145
+
146
+
147
+ def _is_peak(now: datetime, snapshot: Mapping[str, Any]) -> bool:
148
+ tz = ZoneInfo(str(snapshot.get("timezone") or "UTC"))
149
+ local = now.astimezone(tz)
150
+ hour = local.hour + local.minute / 60.0
151
+ return any(float(start) <= hour < float(end) for start, end in snapshot.get("peak_windows") or [])
152
+
153
+
154
+ def calculate_cost(
155
+ event: Mapping[str, Any],
156
+ price_snapshot: Mapping[str, Any],
157
+ *,
158
+ monetary_cost_class: str = "",
159
+ ) -> dict[str, Any]:
160
+ cost_class = str(monetary_cost_class or "").strip().lower()
161
+ if cost_class == "free":
162
+ return {"estimated_cost": 0.0, "currency": "", "billing_window": "free"}
163
+ model = str(event.get("model") or "")
164
+ rates_by_model = dict(price_snapshot.get("per_million_tokens") or {}).get(model)
165
+ if not isinstance(rates_by_model, Mapping):
166
+ return {"estimated_cost": None, "currency": str(price_snapshot.get("currency") or ""), "billing_window": "unknown"}
167
+ started = str(event.get("request_started_at") or "")
168
+ try:
169
+ now = datetime.fromisoformat(started.replace("Z", "+00:00")) if started else datetime.now(timezone.utc)
170
+ except ValueError:
171
+ now = datetime.now(timezone.utc)
172
+ window = "peak" if _is_peak(now, price_snapshot) else "off_peak"
173
+ rates = dict(rates_by_model.get(window) or {})
174
+ million = 1_000_000.0
175
+ cost = (
176
+ float(event.get("cache_hit_tokens") or 0) * float(rates.get("cache_hit_input") or 0) / million
177
+ + float(event.get("cache_miss_tokens") or 0) * float(rates.get("cache_miss_input") or 0) / million
178
+ + float(event.get("output_tokens") or 0) * float(rates.get("output") or 0) / million
179
+ )
180
+ return {
181
+ "estimated_cost": round(cost, 8),
182
+ "currency": str(price_snapshot.get("currency") or ""),
183
+ "billing_window": window,
184
+ "price_snapshot_id": str(price_snapshot.get("id") or ""),
185
+ }
186
+
187
+
188
+ def _result_preview(text: str, limit: int = 240) -> str:
189
+ return re.sub(r"\s+", " ", str(text or "")).strip()[:limit]
190
+
191
+
192
+ def _output_hash(text: str) -> str:
193
+ return hashlib.sha256(str(text or "").encode("utf-8")).hexdigest()
194
+
195
+
196
+ def _profile_route(agent: SSciPipelineAgent, capability: str, provider: str) -> None:
197
+ agent.set_model_policy_snapshot({
198
+ "model_routes": {
199
+ capability: {
200
+ "provider": provider,
201
+ "provider_role": capability,
202
+ "fallback_providers": [],
203
+ }
204
+ }
205
+ })
206
+
207
+
208
+ def run_case(
209
+ *,
210
+ agent: SSciPipelineAgent,
211
+ provider_adapter: Any = None,
212
+ case: Mapping[str, Any],
213
+ profile_id: str,
214
+ profile: Mapping[str, Any],
215
+ benchmark_version: str,
216
+ usage_sink: MemoryUsageSink,
217
+ price_snapshot: Mapping[str, Any],
218
+ ) -> dict[str, Any]:
219
+ provider = str(profile.get("provider") or "").strip().lower()
220
+ capability = str(case.get("capability") or "reasoning").strip().lower()
221
+ model = str(profile.get("model") or "").strip()
222
+ context = {
223
+ "case_id": str(case.get("case_id") or ""),
224
+ "benchmark_version": benchmark_version,
225
+ "profile_id": profile_id,
226
+ "phase_id": capability,
227
+ "attempt_index": 0,
228
+ }
229
+ agent.set_model_usage_sink(usage_sink, context)
230
+ agent.set_model_usage_context(**context)
231
+ if provider_adapter is not None:
232
+ if hasattr(provider_adapter, "usage_sink"):
233
+ provider_adapter.usage_sink = usage_sink
234
+ if hasattr(provider_adapter, "set_usage_context") and callable(provider_adapter.set_usage_context):
235
+ provider_adapter.set_usage_context(**context)
236
+ _profile_route(agent, capability, provider)
237
+
238
+ before = len(usage_sink.events)
239
+ output = ""
240
+ error = None
241
+ try:
242
+ output = agent.call_model(
243
+ str(case.get("system_prompt") or ""),
244
+ str(case.get("user_prompt") or ""),
245
+ max_tokens=int(case.get("max_tokens") or 8000),
246
+ temp=float(case.get("temperature") or 0.2),
247
+ model_name=model or None,
248
+ model_role=capability,
249
+ thinking=bool(profile.get("thinking", False)),
250
+ reasoning_effort=profile.get("reasoning_effort"),
251
+ allow_fallback=bool(profile.get("allow_fallback", False)),
252
+ response_format=case.get("response_format") if isinstance(case.get("response_format"), dict) else None,
253
+ )
254
+ except Exception as exc:
255
+ error = exc
256
+
257
+ new_events = usage_sink.events[before:]
258
+ usage_rows = [event.to_dict() for event in new_events]
259
+ usage = usage_rows[-1] if usage_rows else {}
260
+ evaluation = evaluate_case(output, case) if error is None else {
261
+ "passed": False,
262
+ "critical": bool(case.get("critical", True)),
263
+ "gate_results": [],
264
+ }
265
+ cost_class = str(profile.get("monetary_cost_class") or "").strip().lower()
266
+ cost = calculate_cost(usage, price_snapshot, monetary_cost_class=cost_class) if usage else {
267
+ "estimated_cost": 0.0 if cost_class == "free" else None,
268
+ "currency": "",
269
+ "billing_window": "no_usage",
270
+ }
271
+ return {
272
+ "case_id": str(case.get("case_id") or ""),
273
+ "stage": str(case.get("stage") or ""),
274
+ "benchmark_version": benchmark_version,
275
+ "profile_id": profile_id,
276
+ "provider": provider,
277
+ "model": model,
278
+ "capability": capability,
279
+ "thinking": bool(profile.get("thinking", False)),
280
+ "reasoning_effort": profile.get("reasoning_effort"),
281
+ "max_tokens": int(case.get("max_tokens") or 8000),
282
+ "passed": bool(evaluation["passed"]),
283
+ "critical": bool(evaluation["critical"]),
284
+ "gate_results": evaluation["gate_results"],
285
+ "error_class": error.__class__.__name__ if error else "",
286
+ "error_message": str(error)[:500] if error else "",
287
+ "output_hash": _output_hash(output) if output else "",
288
+ "output_preview": _result_preview(output),
289
+ "usage": usage,
290
+ "cost": cost,
291
+ }
292
+
293
+
294
+ def summarize(results: list[Mapping[str, Any]], stage: str) -> dict[str, Any]:
295
+ critical = [row for row in results if row.get("critical")]
296
+ critical_pass = all(row.get("passed") for row in critical) if critical else False
297
+ passed = sum(1 for row in results if row.get("passed"))
298
+ total_cost = 0.0
299
+ cost_known = True
300
+ for row in results:
301
+ value = (row.get("cost") or {}).get("estimated_cost")
302
+ if value is None:
303
+ cost_known = False
304
+ else:
305
+ total_cost += float(value or 0.0)
306
+ promotion = f"{stage.upper()}_PASS" if critical_pass else "BLOCKED"
307
+ return {
308
+ "stage": stage,
309
+ "promotion_status": promotion,
310
+ "cases_total": len(results),
311
+ "cases_passed": passed,
312
+ "critical_cases": len(critical),
313
+ "critical_passed": sum(1 for row in critical if row.get("passed")),
314
+ "first_pass_rate": (passed / len(results)) if results else 0.0,
315
+ "estimated_cost_total": round(total_cost, 8) if cost_known else None,
316
+ }
317
+
318
+
319
+ def _provider_config(fixture: Mapping[str, Any], provider: str) -> dict[str, Any]:
320
+ configs = fixture.get("provider_adapters")
321
+ if not isinstance(configs, Mapping):
322
+ return {}
323
+ row = configs.get(provider)
324
+ return dict(row) if isinstance(row, Mapping) else {}
325
+
326
+
327
+ def _env_from_config(config: Mapping[str, Any], field: str) -> str:
328
+ env_name = str(config.get(field) or "").strip()
329
+ return str(os.environ.get(env_name) or "").strip() if env_name else ""
330
+
331
+
332
+ def _build_external_adapter(
333
+ *,
334
+ provider: str,
335
+ profile: Mapping[str, Any],
336
+ fixture: Mapping[str, Any],
337
+ usage_sink: MemoryUsageSink,
338
+ ) -> Any:
339
+ config = _provider_config(fixture, provider)
340
+ adapter_type = str(config.get("adapter_type") or "").strip().lower()
341
+ api_key = _env_from_config(config, "api_key_env")
342
+ base_url = _env_from_config(config, "base_url_env") or str(config.get("base_url") or "").strip() or None
343
+ model = str(profile.get("model") or "").strip() or None
344
+ timeout_seconds = float(config.get("timeout_seconds") or 120.0)
345
+
346
+ if adapter_type == "gemini_rest":
347
+ return GeminiRestAdapter(
348
+ api_key=api_key or None,
349
+ model_name=model,
350
+ base_url=base_url,
351
+ timeout_seconds=timeout_seconds,
352
+ usage_sink=usage_sink,
353
+ )
354
+ if adapter_type == "zhipu_glm_rest":
355
+ return ZhipuGLMRestAdapter(
356
+ api_key=api_key or None,
357
+ model_name=model,
358
+ base_url=base_url,
359
+ timeout_seconds=timeout_seconds,
360
+ usage_sink=usage_sink,
361
+ )
362
+ raise ValueError(f"unsupported external adapter type for {provider}: {adapter_type or '<empty>'}")
363
+
364
+
365
+ def select_profile_by_health(
366
+ *,
367
+ fixture: Mapping[str, Any],
368
+ policy_id: str,
369
+ health: Mapping[str, Any],
370
+ ) -> str:
371
+ """Config-driven route selection for benchmark simulations.
372
+
373
+ The function deliberately consumes provider health observations rather than
374
+ country codes or browser-supplied location. Production activation is out
375
+ of scope for A3-R5C; this helper only proves that future CN/non-CN switching
376
+ can be policy-driven without geography hard-coded in application logic.
377
+ """
378
+ policies = fixture.get("routing_policies")
379
+ if not isinstance(policies, Mapping):
380
+ raise ValueError("benchmark routing_policies are missing")
381
+ policy = policies.get(policy_id)
382
+ if not isinstance(policy, Mapping):
383
+ raise ValueError(f"unknown routing policy: {policy_id}")
384
+ allowed_cost_classes = {str(v).strip().lower() for v in policy.get("allowed_cost_classes") or []}
385
+ profiles = fixture.get("profiles") if isinstance(fixture.get("profiles"), Mapping) else {}
386
+ max_latency_ms = float(policy.get("max_latency_ms") or 0)
387
+ max_error_rate = float(policy.get("max_error_rate") or 0)
388
+ skip_rate_limited = bool(policy.get("skip_rate_limited", True))
389
+ for profile_id in policy.get("candidate_profiles") or []:
390
+ profile_id = str(profile_id or "").strip()
391
+ profile = profiles.get(profile_id) if isinstance(profiles, Mapping) else None
392
+ if not isinstance(profile, Mapping):
393
+ continue
394
+ cost_class = str(profile.get("monetary_cost_class") or "").strip().lower()
395
+ if allowed_cost_classes and cost_class not in allowed_cost_classes:
396
+ continue
397
+ provider = str(profile.get("provider") or "").strip().lower()
398
+ provider_health = health.get(provider)
399
+ if isinstance(provider_health, Mapping):
400
+ available = bool(provider_health.get("available", False))
401
+ latency_ms = float(provider_health.get("latency_ms") or 0)
402
+ error_rate = float(provider_health.get("error_rate") or 0)
403
+ rate_limited = bool(provider_health.get("rate_limited", False))
404
+ else:
405
+ available = bool(provider_health)
406
+ latency_ms = 0.0
407
+ error_rate = 0.0
408
+ rate_limited = False
409
+ if not available:
410
+ continue
411
+ if skip_rate_limited and rate_limited:
412
+ continue
413
+ if max_latency_ms > 0 and latency_ms > max_latency_ms:
414
+ continue
415
+ if max_error_rate > 0 and error_rate > max_error_rate:
416
+ continue
417
+ return profile_id
418
+ raise RuntimeError(f"no_healthy_provider_for_policy:{policy_id}")
419
+
420
+
421
+ def run_benchmark(
422
+ *,
423
+ stage: str,
424
+ profile_id: str,
425
+ fixture_path: str | Path = DEFAULT_FIXTURE,
426
+ output_dir: str | Path,
427
+ ) -> dict[str, Any]:
428
+ fixture = load_fixture(fixture_path)
429
+ profile = dict(fixture["profiles"].get(profile_id) or {})
430
+ if not profile:
431
+ raise ValueError(f"unknown profile: {profile_id}")
432
+ provider = str(profile.get("provider") or "").lower()
433
+ provider_config = _provider_config(fixture, provider)
434
+ if provider != "deepseek" and not provider_config:
435
+ raise ValueError(f"unsupported provider: {provider}")
436
+ cases = [row for row in fixture["cases"] if str(row.get("stage") or "").lower() == stage.lower()]
437
+ if not cases:
438
+ raise ValueError(f"no cases for stage: {stage}")
439
+
440
+ output_root = Path(output_dir)
441
+ output_root.mkdir(parents=True, exist_ok=True)
442
+ telemetry_path = output_root / f"{stage}_{profile_id}_usage.jsonl"
443
+ result_path = output_root / f"{stage}_{profile_id}_results.jsonl"
444
+ summary_path = output_root / f"{stage}_{profile_id}_summary.json"
445
+ usage_sink = MemoryUsageSink(JsonlModelUsageSink(telemetry_path))
446
+
447
+ deepseek_config = _provider_config(fixture, "deepseek")
448
+ deepseek_key = _env_from_config(deepseek_config, "api_key_env")
449
+ agent = SSciPipelineAgent(ds_key=deepseek_key)
450
+ provider_adapter = None
451
+ if provider != "deepseek":
452
+ provider_adapter = _build_external_adapter(
453
+ provider=provider,
454
+ profile=profile,
455
+ fixture=fixture,
456
+ usage_sink=usage_sink,
457
+ )
458
+ agent.model_gateway.register_provider(
459
+ provider,
460
+ call_adapter=provider_adapter.call,
461
+ stream_adapter=provider_adapter.stream,
462
+ )
463
+
464
+ results = []
465
+ with result_path.open("w", encoding="utf-8") as handle:
466
+ for case in cases:
467
+ row = run_case(
468
+ agent=agent,
469
+ provider_adapter=provider_adapter,
470
+ case=case,
471
+ profile_id=profile_id,
472
+ profile=profile,
473
+ benchmark_version=str(fixture.get("benchmark_version") or ""),
474
+ usage_sink=usage_sink,
475
+ price_snapshot=dict(fixture.get("price_snapshot") or {}),
476
+ )
477
+ results.append(row)
478
+ handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
479
+ if row["critical"] and not row["passed"]:
480
+ # Cost-conscious stop: a critical deterministic failure blocks
481
+ # promotion; later cases should not consume provider capacity.
482
+ break
483
+
484
+ summary = summarize(results, stage.lower())
485
+ summary.update({
486
+ "profile_id": profile_id,
487
+ "provider": provider,
488
+ "benchmark_version": str(fixture.get("benchmark_version") or ""),
489
+ "result_file": result_path.name,
490
+ "usage_file": telemetry_path.name,
491
+ })
492
+ summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
493
+ return summary
494
+
495
+
496
+ def main(argv: list[str] | None = None) -> int:
497
+ parser = argparse.ArgumentParser(description="SmartPaper A3 isolated provider benchmark")
498
+ parser.add_argument("--stage", choices=("b1", "b2"), required=True)
499
+ parser.add_argument("--profile", required=True)
500
+ parser.add_argument("--fixture", default=str(DEFAULT_FIXTURE))
501
+ parser.add_argument("--output-dir", required=True)
502
+ args = parser.parse_args(argv)
503
+ summary = run_benchmark(
504
+ stage=args.stage,
505
+ profile_id=args.profile,
506
+ fixture_path=args.fixture,
507
+ output_dir=args.output_dir,
508
+ )
509
+ print(json.dumps(summary, ensure_ascii=False, indent=2, sort_keys=True))
510
+ return 0 if summary["promotion_status"].endswith("_PASS") else 2
511
+
512
+
513
+ if __name__ == "__main__":
514
+ raise SystemExit(main())
benchmarks/fixtures/a3_benchmark_cases.json ADDED
@@ -0,0 +1,617 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "benchmark_version": "a3-provider-benchmark-v2",
3
+ "notes": "Synthetic B1/B2 benchmark cases only; no live user prompts, credentials, payment, quota, or manuscript data. Provider/model/endpoints and environment-routing preferences are versioned configuration. Runtime geography is not hard-coded; any future production switching must use server-authoritative provider health/reachability and frozen task policy.",
4
+ "profiles": {
5
+ "gemini_free_mechanical": {
6
+ "provider": "gemini",
7
+ "model": "gemini-2.5-flash",
8
+ "thinking": false,
9
+ "reasoning_effort": "low",
10
+ "allow_fallback": false,
11
+ "monetary_cost_class": "free"
12
+ },
13
+ "gemini_free_reasoning": {
14
+ "provider": "gemini",
15
+ "model": "gemini-2.5-flash",
16
+ "thinking": true,
17
+ "reasoning_effort": "low",
18
+ "allow_fallback": false,
19
+ "monetary_cost_class": "free"
20
+ },
21
+ "deepseek_flash_economy": {
22
+ "provider": "deepseek",
23
+ "model": "deepseek-v4-flash",
24
+ "thinking": false,
25
+ "reasoning_effort": "low",
26
+ "allow_fallback": false,
27
+ "monetary_cost_class": "paid"
28
+ },
29
+ "deepseek_pro_premium": {
30
+ "provider": "deepseek",
31
+ "model": "deepseek-v4-pro",
32
+ "thinking": true,
33
+ "reasoning_effort": "high",
34
+ "allow_fallback": false,
35
+ "monetary_cost_class": "paid"
36
+ },
37
+ "zhipu_glm47_free_no_thinking": {
38
+ "provider": "zhipu",
39
+ "model": "glm-4.7-flash",
40
+ "thinking": false,
41
+ "allow_fallback": false,
42
+ "monetary_cost_class": "free"
43
+ },
44
+ "zhipu_glm47_free_thinking": {
45
+ "provider": "zhipu",
46
+ "model": "glm-4.7-flash",
47
+ "thinking": true,
48
+ "allow_fallback": false,
49
+ "monetary_cost_class": "free"
50
+ }
51
+ },
52
+ "price_snapshot": {
53
+ "id": "deepseek-v4-cn-20260817",
54
+ "currency": "CNY",
55
+ "effective_from": "2026-08-17T00:00:00+08:00",
56
+ "timezone": "Asia/Shanghai",
57
+ "peak_windows": [
58
+ [
59
+ 9,
60
+ 12
61
+ ],
62
+ [
63
+ 14,
64
+ 18
65
+ ]
66
+ ],
67
+ "per_million_tokens": {
68
+ "deepseek-v4-flash": {
69
+ "off_peak": {
70
+ "cache_hit_input": 0.05,
71
+ "cache_miss_input": 1.5,
72
+ "output": 4.5
73
+ },
74
+ "peak": {
75
+ "cache_hit_input": 0.1,
76
+ "cache_miss_input": 3.0,
77
+ "output": 9.0
78
+ }
79
+ },
80
+ "deepseek-v4-pro": {
81
+ "off_peak": {
82
+ "cache_hit_input": 0.15,
83
+ "cache_miss_input": 4.5,
84
+ "output": 13.5
85
+ },
86
+ "peak": {
87
+ "cache_hit_input": 0.3,
88
+ "cache_miss_input": 9.0,
89
+ "output": 27.0
90
+ }
91
+ }
92
+ },
93
+ "source": "DeepSeek official Models & Pricing, verified 2026-08-30"
94
+ },
95
+ "cases": [
96
+ {
97
+ "case_id": "B1-CIT-SUPPORTED",
98
+ "stage": "b1",
99
+ "capability": "reasoning",
100
+ "critical": true,
101
+ "system_prompt": "Judge whether the evidence directly supports the claim. Return exactly SUPPORTED or UNSUPPORTED.",
102
+ "user_prompt": "CLAIM: Perceived personalization is positively associated with learning engagement. EVIDENCE: In a cross-sectional survey, perceived personalization had a positive standardized association with learning engagement (beta = 0.35, 95% CI [0.22, 0.48]).",
103
+ "max_tokens": 64,
104
+ "gates": [
105
+ {
106
+ "type": "exact_ci",
107
+ "expected": "SUPPORTED"
108
+ }
109
+ ]
110
+ },
111
+ {
112
+ "case_id": "B1-CIT-UNSUPPORTED",
113
+ "stage": "b1",
114
+ "capability": "reasoning",
115
+ "critical": true,
116
+ "system_prompt": "Judge whether the evidence directly supports the claim. Return exactly SUPPORTED or UNSUPPORTED.",
117
+ "user_prompt": "CLAIM: Generative AI reduces employee turnover by 30 percent. EVIDENCE: A qualitative interview study reports that employees discussed faster access to information, but it contains no turnover measure or percentage estimate.",
118
+ "max_tokens": 64,
119
+ "gates": [
120
+ {
121
+ "type": "exact_ci",
122
+ "expected": "UNSUPPORTED"
123
+ }
124
+ ]
125
+ },
126
+ {
127
+ "case_id": "B1-QUANT-CAUSAL",
128
+ "stage": "b1",
129
+ "capability": "repair",
130
+ "critical": true,
131
+ "system_prompt": "Rewrite the sentence so that a cross-sectional observational design is described without causal inference. Return one sentence only.",
132
+ "user_prompt": "Rewrite: AI-enabled planning causes lower decision anxiety and therefore leads to higher purchase intention.",
133
+ "max_tokens": 96,
134
+ "gates": [
135
+ {
136
+ "type": "contains_any_ci",
137
+ "values": [
138
+ "associated",
139
+ "association",
140
+ "linked",
141
+ "relationship"
142
+ ]
143
+ },
144
+ {
145
+ "type": "forbid_any_ci",
146
+ "values": [
147
+ " causes ",
148
+ " caused ",
149
+ " leads to ",
150
+ " results in ",
151
+ " drives "
152
+ ]
153
+ }
154
+ ]
155
+ },
156
+ {
157
+ "case_id": "B1-QUANT-METRIC",
158
+ "stage": "b1",
159
+ "capability": "repair",
160
+ "critical": true,
161
+ "system_prompt": "Correct the result sentence using only the frozen metrics. Return one sentence and do not invent values.",
162
+ "user_prompt": "Frozen metrics: X→M standardized estimate = 0.21; M→Y standardized estimate = 0.35. Draft: X→M = 0.351 and M→Y = 0.21.",
163
+ "max_tokens": 96,
164
+ "gates": [
165
+ {
166
+ "type": "contains_all_ci",
167
+ "values": [
168
+ "0.21",
169
+ "0.35"
170
+ ]
171
+ },
172
+ {
173
+ "type": "forbid_any_ci",
174
+ "values": [
175
+ "0.351"
176
+ ]
177
+ }
178
+ ]
179
+ },
180
+ {
181
+ "case_id": "B1-QUAL-SOURCE",
182
+ "stage": "b1",
183
+ "capability": "repair",
184
+ "critical": true,
185
+ "system_prompt": "Repair the methods sentence to match the frozen study-fact contract. Return one sentence only.",
186
+ "user_prompt": "Contract: actors=farmers; allowed data sources=semi-structured interviews and system logs. Draft: We observed farmers in the field to understand their experiences.",
187
+ "max_tokens": 96,
188
+ "gates": [
189
+ {
190
+ "type": "contains_all_ci",
191
+ "values": [
192
+ "farmers",
193
+ "interview"
194
+ ]
195
+ },
196
+ {
197
+ "type": "forbid_any_ci",
198
+ "values": [
199
+ "observed farmers",
200
+ "observation"
201
+ ]
202
+ }
203
+ ]
204
+ },
205
+ {
206
+ "case_id": "B1-JSON",
207
+ "stage": "b1",
208
+ "capability": "fast",
209
+ "critical": true,
210
+ "system_prompt": "Return JSON only with exactly the requested keys.",
211
+ "user_prompt": "Return an object with keys label and confidence. label must be supported; confidence must be high.",
212
+ "max_tokens": 96,
213
+ "response_format": {
214
+ "type": "json_object"
215
+ },
216
+ "gates": [
217
+ {
218
+ "type": "json_keys_exact",
219
+ "keys": [
220
+ "label",
221
+ "confidence"
222
+ ]
223
+ },
224
+ {
225
+ "type": "json_value_ci",
226
+ "key": "label",
227
+ "expected": "supported"
228
+ },
229
+ {
230
+ "type": "json_value_ci",
231
+ "key": "confidence",
232
+ "expected": "high"
233
+ }
234
+ ]
235
+ },
236
+ {
237
+ "case_id": "B1-EN-DRAFT",
238
+ "stage": "b1",
239
+ "capability": "writing",
240
+ "critical": true,
241
+ "system_prompt": "Write concise academic prose without meta-commentary about AI writing or the drafting process.",
242
+ "user_prompt": "In 110-140 English words, explain why construct validity matters when evaluating a multi-item measure in an empirical management study.",
243
+ "max_tokens": 260,
244
+ "gates": [
245
+ {
246
+ "type": "word_range",
247
+ "min": 100,
248
+ "max": 155
249
+ },
250
+ {
251
+ "type": "forbid_any_ci",
252
+ "values": [
253
+ "as an ai",
254
+ "ai-generated response",
255
+ "this draft",
256
+ "i cannot"
257
+ ]
258
+ }
259
+ ]
260
+ },
261
+ {
262
+ "case_id": "B1-ZH-TRANSLATION",
263
+ "stage": "b1",
264
+ "capability": "translation",
265
+ "critical": true,
266
+ "system_prompt": "Translate into formal academic Chinese. Preserve the meaning and do not add commentary.",
267
+ "user_prompt": "Generative artificial intelligence is associated with changes in perceived decision support, but the cross-sectional design does not establish causality.",
268
+ "max_tokens": 180,
269
+ "gates": [
270
+ {
271
+ "type": "contains_all_ci",
272
+ "values": [
273
+ "生成式",
274
+ "横断面"
275
+ ]
276
+ },
277
+ {
278
+ "type": "script_min_ratio",
279
+ "script": "han",
280
+ "min": 0.35
281
+ }
282
+ ]
283
+ },
284
+ {
285
+ "case_id": "B1-RU-TRANSLATION",
286
+ "stage": "b1",
287
+ "capability": "translation",
288
+ "critical": true,
289
+ "system_prompt": "Translate into formal academic Russian. Preserve the non-causal meaning and do not add commentary.",
290
+ "user_prompt": "Generative artificial intelligence is associated with changes in perceived decision support, but the cross-sectional design does not establish causality.",
291
+ "max_tokens": 180,
292
+ "gates": [
293
+ {
294
+ "type": "contains_any_ci",
295
+ "values": [
296
+ "генератив",
297
+ "искусственн"
298
+ ]
299
+ },
300
+ {
301
+ "type": "contains_any_ci",
302
+ "values": [
303
+ "попереч",
304
+ "кросс-секц",
305
+ "причин"
306
+ ]
307
+ },
308
+ {
309
+ "type": "script_min_ratio",
310
+ "script": "cyrillic",
311
+ "min": 0.55
312
+ }
313
+ ]
314
+ },
315
+ {
316
+ "case_id": "B1-KK-TRANSLATION",
317
+ "stage": "b1",
318
+ "capability": "translation",
319
+ "critical": true,
320
+ "system_prompt": "Translate into formal academic Kazakh. Preserve the non-causal meaning and do not add commentary.",
321
+ "user_prompt": "Generative artificial intelligence is associated with changes in perceived decision support, but the cross-sectional design does not establish causality.",
322
+ "max_tokens": 180,
323
+ "gates": [
324
+ {
325
+ "type": "contains_any_ci",
326
+ "values": [
327
+ "генеративті",
328
+ "жасанды интеллект"
329
+ ]
330
+ },
331
+ {
332
+ "type": "contains_any_ci",
333
+ "values": [
334
+ "зерттеу",
335
+ "себеп",
336
+ "байланыс"
337
+ ]
338
+ },
339
+ {
340
+ "type": "script_min_ratio",
341
+ "script": "cyrillic",
342
+ "min": 0.55
343
+ }
344
+ ]
345
+ },
346
+ {
347
+ "case_id": "B1-TERM-CONSISTENCY",
348
+ "stage": "b1",
349
+ "capability": "translation",
350
+ "critical": true,
351
+ "system_prompt": "Translate into academic Chinese and use the fixed glossary exactly: algorithmic fidelity=算法保真度; silicon sampling=硅基抽样.",
352
+ "user_prompt": "Algorithmic fidelity is a central validity concern in silicon sampling.",
353
+ "max_tokens": 120,
354
+ "gates": [
355
+ {
356
+ "type": "contains_all_ci",
357
+ "values": [
358
+ "算法保真度",
359
+ "硅基抽样"
360
+ ]
361
+ }
362
+ ]
363
+ },
364
+ {
365
+ "case_id": "B1-LOCAL-REPAIR",
366
+ "stage": "b1",
367
+ "capability": "repair",
368
+ "critical": true,
369
+ "system_prompt": "Repair only the unsupported causal wording. Preserve the numerical result and do not rewrite unrelated content.",
370
+ "user_prompt": "Cross-sectional result: beta = 0.28. Draft: The intervention caused a 0.28 increase in performance.",
371
+ "max_tokens": 100,
372
+ "gates": [
373
+ {
374
+ "type": "contains_all_ci",
375
+ "values": [
376
+ "0.28"
377
+ ]
378
+ },
379
+ {
380
+ "type": "forbid_any_ci",
381
+ "values": [
382
+ "caused",
383
+ "cause a",
384
+ "causes"
385
+ ]
386
+ },
387
+ {
388
+ "type": "contains_any_ci",
389
+ "values": [
390
+ "associated",
391
+ "association",
392
+ "relationship",
393
+ "linked"
394
+ ]
395
+ }
396
+ ]
397
+ },
398
+ {
399
+ "case_id": "B2-QUANT-DISCUSSION",
400
+ "stage": "b2",
401
+ "capability": "writing",
402
+ "critical": true,
403
+ "system_prompt": "Write a Discussion section using only the supplied evidence. Use cautious observational language, preserve all metrics, and include the design limitation.",
404
+ "user_prompt": "Topic: AI-enabled decision support in online travel planning. Frozen evidence: X→M standardized estimate 0.21; M→Y 0.35; indirect effect 0.07 with bootstrap 95% CI [0.03, 0.12]. Design: single-wave cross-sectional survey. Write 450-650 English words. Do not claim causal identification.",
405
+ "max_tokens": 1000,
406
+ "gates": [
407
+ {
408
+ "type": "word_range",
409
+ "min": 400,
410
+ "max": 700
411
+ },
412
+ {
413
+ "type": "contains_all_ci",
414
+ "values": [
415
+ "0.21",
416
+ "0.35",
417
+ "0.07",
418
+ "0.03",
419
+ "0.12",
420
+ "cross-sectional"
421
+ ]
422
+ },
423
+ {
424
+ "type": "forbid_any_ci",
425
+ "values": [
426
+ "caused by",
427
+ "causes",
428
+ "demonstrates that x causes",
429
+ "establishes causality"
430
+ ]
431
+ }
432
+ ]
433
+ },
434
+ {
435
+ "case_id": "B2-QUAL-FINDINGS",
436
+ "stage": "b2",
437
+ "capability": "writing",
438
+ "critical": true,
439
+ "system_prompt": "Write a qualitative Findings subsection. Preserve the quote identity exactly once and do not invent additional participants, data sources, or quotations.",
440
+ "user_prompt": "Study contract: participants=farmers; data sources=semi-structured interviews and system logs. Theme=trust calibration. Required quotation: [QUOTE-01] ‘I check the recommendation against my own field records before I act.’ Write 400-600 English words and include QUOTE-01 exactly once.",
441
+ "max_tokens": 900,
442
+ "gates": [
443
+ {
444
+ "type": "word_range",
445
+ "min": 350,
446
+ "max": 650
447
+ },
448
+ {
449
+ "type": "count_exact_ci",
450
+ "value": "QUOTE-01",
451
+ "count": 1
452
+ },
453
+ {
454
+ "type": "contains_all_ci",
455
+ "values": [
456
+ "farmers",
457
+ "interview",
458
+ "system logs",
459
+ "trust"
460
+ ]
461
+ },
462
+ {
463
+ "type": "forbid_any_ci",
464
+ "values": [
465
+ "participant 99",
466
+ "focus group",
467
+ "observation data"
468
+ ]
469
+ }
470
+ ]
471
+ },
472
+ {
473
+ "case_id": "B2-CITATION-BOUND",
474
+ "stage": "b2",
475
+ "capability": "writing",
476
+ "critical": true,
477
+ "system_prompt": "Write a theoretical-background subsection using only the two evidence records. Put the matching citation tag immediately after the supported claim. Do not transfer evidence across tags.",
478
+ "user_prompt": "Evidence [CITE-01]: Transactive memory systems improve coordination by helping members know who has expertise. Evidence [CITE-02]: AI literacy includes the ability to critically evaluate AI outputs. Write 350-500 English words connecting the two ideas without inventing empirical effects.",
479
+ "max_tokens": 850,
480
+ "gates": [
481
+ {
482
+ "type": "word_range",
483
+ "min": 300,
484
+ "max": 550
485
+ },
486
+ {
487
+ "type": "contains_all_ci",
488
+ "values": [
489
+ "[CITE-01]",
490
+ "[CITE-02]",
491
+ "expertise",
492
+ "critically"
493
+ ]
494
+ }
495
+ ]
496
+ },
497
+ {
498
+ "case_id": "B2-KK-SECTION",
499
+ "stage": "b2",
500
+ "capability": "translation",
501
+ "critical": true,
502
+ "system_prompt": "Translate the supplied academic paragraph into formal Kazakh. Preserve all numbers and the non-causal limitation. Do not translate the citation tag.",
503
+ "user_prompt": "Perceived personalization was positively associated with learning engagement (beta = 0.35) [CITE-07]. Because the study used a cross-sectional design, the estimate should not be interpreted as evidence of causality. The result is therefore discussed as an association rather than an identified causal effect.",
504
+ "max_tokens": 400,
505
+ "gates": [
506
+ {
507
+ "type": "contains_all_ci",
508
+ "values": [
509
+ "0.35",
510
+ "[CITE-07]"
511
+ ]
512
+ },
513
+ {
514
+ "type": "contains_any_ci",
515
+ "values": [
516
+ "байланыс",
517
+ "себеп",
518
+ "зерттеу"
519
+ ]
520
+ },
521
+ {
522
+ "type": "script_min_ratio",
523
+ "script": "cyrillic",
524
+ "min": 0.55
525
+ }
526
+ ]
527
+ }
528
+ ],
529
+ "provider_adapters": {
530
+ "deepseek": {
531
+ "adapter_type": "pipeline_deepseek",
532
+ "api_key_env": "DEEPSEEK_API_KEY"
533
+ },
534
+ "gemini": {
535
+ "adapter_type": "gemini_rest",
536
+ "api_key_env": "GEMINI_API_KEY",
537
+ "base_url_env": "GEMINI_API_BASE_URL",
538
+ "base_url": "https://generativelanguage.googleapis.com/v1beta",
539
+ "timeout_seconds": 120
540
+ },
541
+ "zhipu": {
542
+ "adapter_type": "zhipu_glm_rest",
543
+ "api_key_env": "ZHIPU_API_KEY",
544
+ "base_url_env": "ZHIPU_API_BASE_URL",
545
+ "base_url": "https://open.bigmodel.cn/api/paas/v4",
546
+ "timeout_seconds": 120
547
+ }
548
+ },
549
+ "routing_policies": {
550
+ "free_auto_v1": {
551
+ "status": "benchmark_design_only",
552
+ "selection_strategy": "server_health_then_config_priority",
553
+ "candidate_profiles": [
554
+ "zhipu_glm47_free_no_thinking",
555
+ "gemini_free_mechanical"
556
+ ],
557
+ "allowed_cost_classes": [
558
+ "free"
559
+ ],
560
+ "max_error_rate": 0.25,
561
+ "max_latency_ms": 120000,
562
+ "skip_rate_limited": true,
563
+ "decision_inputs": [
564
+ "server_reachability",
565
+ "recent_provider_health",
566
+ "rate_limit_state",
567
+ "latency"
568
+ ],
569
+ "client_region_authoritative": false
570
+ },
571
+ "standard_resilience_v1": {
572
+ "status": "benchmark_design_only",
573
+ "selection_strategy": "server_health_then_config_priority",
574
+ "candidate_profiles": [
575
+ "deepseek_flash_economy",
576
+ "zhipu_glm47_free_thinking"
577
+ ],
578
+ "allowed_cost_classes": [
579
+ "paid",
580
+ "free"
581
+ ],
582
+ "max_error_rate": 0.25,
583
+ "max_latency_ms": 120000,
584
+ "skip_rate_limited": true,
585
+ "decision_inputs": [
586
+ "server_reachability",
587
+ "recent_provider_health",
588
+ "rate_limit_state",
589
+ "latency"
590
+ ],
591
+ "client_region_authoritative": false
592
+ },
593
+ "flagship_resilience_v1": {
594
+ "status": "benchmark_design_only",
595
+ "selection_strategy": "server_health_then_config_priority",
596
+ "candidate_profiles": [
597
+ "deepseek_pro_premium",
598
+ "deepseek_flash_economy",
599
+ "zhipu_glm47_free_thinking"
600
+ ],
601
+ "allowed_cost_classes": [
602
+ "paid",
603
+ "free"
604
+ ],
605
+ "max_error_rate": 0.25,
606
+ "max_latency_ms": 120000,
607
+ "skip_rate_limited": true,
608
+ "decision_inputs": [
609
+ "server_reachability",
610
+ "recent_provider_health",
611
+ "rate_limit_state",
612
+ "latency"
613
+ ],
614
+ "client_region_authoritative": false
615
+ }
616
+ }
617
+ }
core/model_usage.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Provider-neutral model usage telemetry for SmartPaper benchmarks.
3
+
4
+ A3-R5 intentionally keeps this module independent from product entitlement,
5
+ payment, quota, and production routing. It normalizes provider usage metadata
6
+ and emits append-only benchmark records without retaining prompts or secrets.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import asdict, dataclass, field
11
+ from datetime import datetime, timezone
12
+ import hashlib
13
+ import json
14
+ from pathlib import Path
15
+ import threading
16
+ import uuid
17
+ from typing import Any, Mapping
18
+
19
+
20
+ def _utc_now_iso() -> str:
21
+ return datetime.now(timezone.utc).isoformat()
22
+
23
+
24
+ def _safe_int(value: Any) -> int:
25
+ try:
26
+ return max(0, int(value or 0))
27
+ except (TypeError, ValueError):
28
+ return 0
29
+
30
+
31
+ def _safe_text(value: Any, limit: int = 240) -> str:
32
+ text = str(value or "").replace("\r", " ").replace("\n", " ").strip()
33
+ return text[:limit]
34
+
35
+
36
+ def sanitize_metadata(value: Any, *, depth: int = 0) -> Any:
37
+ """Return a bounded, secret-safe JSON-compatible metadata value."""
38
+ if depth > 4:
39
+ return "<max-depth>"
40
+ if value is None or isinstance(value, (bool, int, float)):
41
+ return value
42
+ if isinstance(value, str):
43
+ lowered = value.lower()
44
+ if any(marker in lowered for marker in ("bearer ", "api_key", "api-key", "x-goog-api-key")):
45
+ return "<redacted>"
46
+ return value[:500]
47
+ if isinstance(value, Mapping):
48
+ clean = {}
49
+ for key, item in list(value.items())[:40]:
50
+ name = str(key)
51
+ lowered = name.lower()
52
+ if any(marker in lowered for marker in ("key", "token", "authorization", "secret", "credential", "prompt", "manuscript")):
53
+ clean[name] = "<redacted>"
54
+ else:
55
+ clean[name] = sanitize_metadata(item, depth=depth + 1)
56
+ return clean
57
+ if isinstance(value, (list, tuple)):
58
+ return [sanitize_metadata(item, depth=depth + 1) for item in list(value)[:40]]
59
+ return _safe_text(value, 500)
60
+
61
+
62
+ def prompt_identity(system_prompt: Any = "", user_prompt: Any = "") -> str:
63
+ """Hash prompt identity without storing the prompt itself."""
64
+ payload = (str(system_prompt or "") + "\n<SEP>\n" + str(user_prompt or "")).encode("utf-8")
65
+ return hashlib.sha256(payload).hexdigest()
66
+
67
+
68
+ @dataclass
69
+ class ModelUsageEvent:
70
+ provider: str
71
+ model: str
72
+ status: str
73
+ event_id: str = field(default_factory=lambda: str(uuid.uuid4()))
74
+ created_at: str = field(default_factory=_utc_now_iso)
75
+ phase_id: str = ""
76
+ case_id: str = ""
77
+ benchmark_version: str = ""
78
+ profile_id: str = ""
79
+ attempt_index: int = 0
80
+ streaming: bool = False
81
+ thinking_mode: str = ""
82
+ requested_thinking_budget: int | None = None
83
+ prompt_hash: str = ""
84
+ request_started_at: str = ""
85
+ first_token_at: str = ""
86
+ completed_at: str = ""
87
+ latency_ms: int = 0
88
+ prompt_input_tokens: int = 0
89
+ cache_hit_tokens: int = 0
90
+ cache_miss_tokens: int = 0
91
+ output_tokens: int = 0
92
+ reasoning_or_thought_tokens: int = 0
93
+ total_tokens: int = 0
94
+ finish_reason: str = ""
95
+ http_status: int | None = None
96
+ error_class: str = ""
97
+ retry_reason: str = ""
98
+ escalation_reason: str = ""
99
+ provider_service_tier: str = ""
100
+ provider_model_version: str = ""
101
+ provider_response_id: str = ""
102
+ price_snapshot_id: str = ""
103
+ estimated_cost_microunits: int = 0
104
+ currency: str = ""
105
+ metadata: dict[str, Any] = field(default_factory=dict)
106
+
107
+ def to_dict(self) -> dict[str, Any]:
108
+ payload = asdict(self)
109
+ payload["metadata"] = sanitize_metadata(payload.get("metadata") or {})
110
+ return payload
111
+
112
+
113
+ class NoOpModelUsageSink:
114
+ def emit(self, event: ModelUsageEvent | Mapping[str, Any]) -> None:
115
+ return None
116
+
117
+
118
+ class JsonlModelUsageSink:
119
+ """Thread-safe append-only JSONL sink for benchmark artifacts."""
120
+
121
+ def __init__(self, path: str | Path):
122
+ self.path = Path(path)
123
+ self.path.parent.mkdir(parents=True, exist_ok=True)
124
+ self._lock = threading.Lock()
125
+
126
+ def emit(self, event: ModelUsageEvent | Mapping[str, Any]) -> None:
127
+ if isinstance(event, ModelUsageEvent):
128
+ row = event.to_dict()
129
+ else:
130
+ row = sanitize_metadata(dict(event or {}))
131
+ line = json.dumps(row, ensure_ascii=False, sort_keys=True)
132
+ with self._lock:
133
+ with self.path.open("a", encoding="utf-8") as handle:
134
+ handle.write(line + "\n")
135
+
136
+
137
+ def emit_usage_event(sink: Any, event: ModelUsageEvent) -> None:
138
+ """Best-effort telemetry emission that can never fail model generation."""
139
+ if sink is None:
140
+ return
141
+ try:
142
+ if hasattr(sink, "emit") and callable(sink.emit):
143
+ sink.emit(event)
144
+ elif callable(sink):
145
+ sink(event)
146
+ except Exception:
147
+ # Telemetry is strictly observational in A3-R5.
148
+ return
149
+
150
+
151
+ def normalize_deepseek_usage(raw: Mapping[str, Any] | None) -> dict[str, int]:
152
+ usage = dict(raw or {})
153
+ details = usage.get("completion_tokens_details")
154
+ if not isinstance(details, Mapping):
155
+ details = {}
156
+ return {
157
+ "prompt_input_tokens": _safe_int(usage.get("prompt_tokens")),
158
+ "cache_hit_tokens": _safe_int(usage.get("prompt_cache_hit_tokens")),
159
+ "cache_miss_tokens": _safe_int(usage.get("prompt_cache_miss_tokens")),
160
+ "output_tokens": _safe_int(usage.get("completion_tokens")),
161
+ "reasoning_or_thought_tokens": _safe_int(details.get("reasoning_tokens")),
162
+ "total_tokens": _safe_int(usage.get("total_tokens")),
163
+ }
164
+
165
+
166
+ def normalize_gemini_usage(raw: Mapping[str, Any] | None) -> dict[str, int | str]:
167
+ usage = dict(raw or {})
168
+ return {
169
+ "prompt_input_tokens": _safe_int(usage.get("promptTokenCount")),
170
+ "cache_hit_tokens": _safe_int(usage.get("cachedContentTokenCount")),
171
+ "cache_miss_tokens": max(
172
+ 0,
173
+ _safe_int(usage.get("promptTokenCount")) - _safe_int(usage.get("cachedContentTokenCount")),
174
+ ),
175
+ "output_tokens": _safe_int(usage.get("candidatesTokenCount")),
176
+ "reasoning_or_thought_tokens": _safe_int(usage.get("thoughtsTokenCount")),
177
+ "total_tokens": _safe_int(usage.get("totalTokenCount")),
178
+ "provider_service_tier": _safe_text(usage.get("trafficType") or usage.get("serviceTier"), 80),
179
+ }
180
+
181
+ def normalize_zhipu_usage(raw: Mapping[str, Any] | None) -> dict[str, int]:
182
+ """Normalize Zhipu/GLM Chat Completions usage fields.
183
+
184
+ Official GLM responses expose prompt/completion/total token counts and
185
+ prompt_tokens_details.cached_tokens. reasoning_tokens is consumed only
186
+ when the provider actually returns it; it is never inferred from
187
+ reasoning_content text.
188
+ """
189
+ usage = dict(raw or {})
190
+ prompt_details = usage.get("prompt_tokens_details")
191
+ if not isinstance(prompt_details, Mapping):
192
+ prompt_details = {}
193
+ completion_details = usage.get("completion_tokens_details")
194
+ if not isinstance(completion_details, Mapping):
195
+ completion_details = {}
196
+ prompt_tokens = _safe_int(usage.get("prompt_tokens"))
197
+ cached_tokens = _safe_int(prompt_details.get("cached_tokens"))
198
+ return {
199
+ "prompt_input_tokens": prompt_tokens,
200
+ "cache_hit_tokens": cached_tokens,
201
+ "cache_miss_tokens": max(0, prompt_tokens - cached_tokens),
202
+ "output_tokens": _safe_int(usage.get("completion_tokens")),
203
+ "reasoning_or_thought_tokens": _safe_int(completion_details.get("reasoning_tokens")),
204
+ "total_tokens": _safe_int(usage.get("total_tokens")),
205
+ }
206
+
core/pipeline_agent.py CHANGED
@@ -29,6 +29,7 @@ except ImportError:
29
  from config import APP_CONFIG
30
  from core.production_guard import find_authorial_ai_meta_spans
31
  from core.model_gateway import ProviderNeutralModelGateway
 
32
 
33
 
34
  class DeepSeekAPIError(RuntimeError):
@@ -148,6 +149,10 @@ class SSciPipelineAgent:
148
  }
149
  self._cancel_checker = None
150
  self._execution_progress_hook = None
 
 
 
 
151
  # S2 is task-local and auxiliary. A configured key is always used
152
  # first; no username/email is required and the secret is never logged.
153
  # All S2 HTTP attempts (including retries/future endpoints) share one
@@ -239,6 +244,76 @@ class SSciPipelineAgent:
239
  """Register a best-effort callback tied to real backend execution."""
240
  self._execution_progress_hook = hook
241
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  def _touch_execution_progress(self, stage=""):
243
  hook = self._execution_progress_hook
244
  if hook is None:
@@ -905,11 +980,27 @@ class SSciPipelineAgent:
905
  f"finish_reason={finish_reason!r}"
906
  )
907
 
 
908
  logging.info(
909
  "DeepSeek static response | seconds=%.2f | chars=%s",
910
- time.time() - start_time,
911
  len(content),
912
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
913
  return content
914
 
915
  except (
@@ -1009,6 +1100,8 @@ class SSciPipelineAgent:
1009
  stream=True,
1010
  reasoning_effort=resolved_effort,
1011
  )
 
 
1012
 
1013
  emitted_content = False
1014
  reasoning_open = False
@@ -1016,6 +1109,9 @@ class SSciPipelineAgent:
1016
  content_chunks = 0
1017
  content_chars = 0
1018
  first_content_at = None
 
 
 
1019
  start_time = time.time()
1020
  last_model_activity = start_time
1021
 
@@ -1062,10 +1158,20 @@ class SSciPipelineAgent:
1062
  )
1063
  break
1064
 
 
 
 
 
 
 
 
 
1065
  choices = event.get("choices") or []
1066
  if not choices:
1067
  continue
1068
 
 
 
1069
  delta = choices[0].get("delta") or {}
1070
  reasoning_content = (
1071
  delta.get("reasoning_content") or ""
@@ -1130,14 +1236,31 @@ class SSciPipelineAgent:
1130
  "DeepSeek stream ended without final content."
1131
  )
1132
 
 
1133
  self._touch_execution_progress("deepseek_stream_complete")
1134
  logging.info(
1135
  "DeepSeek stream complete | seconds=%.1f | reasoning_chunks=%s | content_chunks=%s | content_chars=%s",
1136
- time.time() - start_time,
1137
  reasoning_chunks,
1138
  content_chunks,
1139
  content_chars,
1140
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1141
  return
1142
 
1143
  except GeneratorExit:
 
29
  from config import APP_CONFIG
30
  from core.production_guard import find_authorial_ai_meta_spans
31
  from core.model_gateway import ProviderNeutralModelGateway
32
+ from core.model_usage import ModelUsageEvent, emit_usage_event, normalize_deepseek_usage, prompt_identity, sanitize_metadata
33
 
34
 
35
  class DeepSeekAPIError(RuntimeError):
 
149
  }
150
  self._cancel_checker = None
151
  self._execution_progress_hook = None
152
+ # A3-R5: observability-only model usage capture. Disabled by default so
153
+ # production request shape and routing remain unchanged.
154
+ self._model_usage_sink = None
155
+ self._model_usage_context = {}
156
  # S2 is task-local and auxiliary. A configured key is always used
157
  # first; no username/email is required and the secret is never logged.
158
  # All S2 HTTP attempts (including retries/future endpoints) share one
 
244
  """Register a best-effort callback tied to real backend execution."""
245
  self._execution_progress_hook = hook
246
 
247
+ def set_model_usage_sink(self, sink=None, context=None):
248
+ """Enable best-effort model usage telemetry for benchmark processes.
249
+
250
+ A3-R5 keeps this disabled by default. The sink is observational only:
251
+ failures in telemetry emission can never fail manuscript generation.
252
+ """
253
+ self._model_usage_sink = sink
254
+ self._model_usage_context = dict(context or {})
255
+
256
+ def set_model_usage_context(self, **context):
257
+ self._model_usage_context = dict(context or {})
258
+
259
+ def _model_usage_enabled(self):
260
+ return getattr(self, "_model_usage_sink", None) is not None
261
+
262
+ @staticmethod
263
+ def _usage_iso(epoch_value):
264
+ if epoch_value is None:
265
+ return ""
266
+ try:
267
+ return datetime.fromtimestamp(float(epoch_value), tz=timezone.utc).isoformat()
268
+ except Exception:
269
+ return ""
270
+
271
+ def _emit_deepseek_usage(
272
+ self, *, usage, model, role, attempt_index, streaming, thinking,
273
+ reasoning_effort, system_prompt, user_prompt, started_at,
274
+ completed_at, first_token_at=None, finish_reason="",
275
+ response_payload=None,
276
+ ):
277
+ if not self._model_usage_enabled():
278
+ return
279
+ try:
280
+ normalized = normalize_deepseek_usage(usage if isinstance(usage, dict) else {})
281
+ context = dict(getattr(self, "_model_usage_context", {}) or {})
282
+ response_payload = dict(response_payload or {})
283
+ event = ModelUsageEvent(
284
+ provider="deepseek",
285
+ model=str(response_payload.get("model") or model or ""),
286
+ status="success",
287
+ phase_id=str(context.get("phase_id") or role or ""),
288
+ case_id=str(context.get("case_id") or ""),
289
+ benchmark_version=str(context.get("benchmark_version") or ""),
290
+ profile_id=str(context.get("profile_id") or ""),
291
+ attempt_index=max(0, int(attempt_index or 0)),
292
+ streaming=bool(streaming),
293
+ thinking_mode="enabled" if thinking else "disabled",
294
+ prompt_hash=prompt_identity(system_prompt, user_prompt),
295
+ request_started_at=self._usage_iso(started_at),
296
+ first_token_at=self._usage_iso(first_token_at),
297
+ completed_at=self._usage_iso(completed_at),
298
+ latency_ms=max(0, int((float(completed_at) - float(started_at)) * 1000)),
299
+ prompt_input_tokens=int(normalized.get("prompt_input_tokens") or 0),
300
+ cache_hit_tokens=int(normalized.get("cache_hit_tokens") or 0),
301
+ cache_miss_tokens=int(normalized.get("cache_miss_tokens") or 0),
302
+ output_tokens=int(normalized.get("output_tokens") or 0),
303
+ reasoning_or_thought_tokens=int(normalized.get("reasoning_or_thought_tokens") or 0),
304
+ total_tokens=int(normalized.get("total_tokens") or 0),
305
+ finish_reason=str(finish_reason or "")[:120],
306
+ provider_model_version=str(response_payload.get("model") or model or "")[:160],
307
+ provider_response_id=str(response_payload.get("id") or "")[:160],
308
+ metadata=sanitize_metadata({
309
+ "system_fingerprint": response_payload.get("system_fingerprint"),
310
+ "reasoning_effort": reasoning_effort,
311
+ }),
312
+ )
313
+ emit_usage_event(getattr(self, "_model_usage_sink", None), event)
314
+ except Exception:
315
+ return
316
+
317
  def _touch_execution_progress(self, stage=""):
318
  hook = self._execution_progress_hook
319
  if hook is None:
 
980
  f"finish_reason={finish_reason!r}"
981
  )
982
 
983
+ completed_at = time.time()
984
  logging.info(
985
  "DeepSeek static response | seconds=%.2f | chars=%s",
986
+ completed_at - start_time,
987
  len(content),
988
  )
989
+ self._emit_deepseek_usage(
990
+ usage=result.get("usage") or {},
991
+ model=current_model,
992
+ role=resolved_role,
993
+ attempt_index=attempt,
994
+ streaming=False,
995
+ thinking=current_thinking,
996
+ reasoning_effort=resolved_effort,
997
+ system_prompt=system_prompt,
998
+ user_prompt=user_prompt,
999
+ started_at=start_time,
1000
+ completed_at=completed_at,
1001
+ finish_reason=choices[0].get("finish_reason"),
1002
+ response_payload=result,
1003
+ )
1004
  return content
1005
 
1006
  except (
 
1100
  stream=True,
1101
  reasoning_effort=resolved_effort,
1102
  )
1103
+ if self._model_usage_enabled():
1104
+ payload["stream_options"] = {"include_usage": True}
1105
 
1106
  emitted_content = False
1107
  reasoning_open = False
 
1109
  content_chunks = 0
1110
  content_chars = 0
1111
  first_content_at = None
1112
+ stream_usage = None
1113
+ stream_finish_reason = ""
1114
+ stream_response_payload = {}
1115
  start_time = time.time()
1116
  last_model_activity = start_time
1117
 
 
1158
  )
1159
  break
1160
 
1161
+ if isinstance(event.get("usage"), dict):
1162
+ stream_usage = dict(event.get("usage") or {})
1163
+ stream_response_payload = {
1164
+ "id": event.get("id"),
1165
+ "model": event.get("model") or current_model,
1166
+ "system_fingerprint": event.get("system_fingerprint"),
1167
+ }
1168
+
1169
  choices = event.get("choices") or []
1170
  if not choices:
1171
  continue
1172
 
1173
+ if choices[0].get("finish_reason"):
1174
+ stream_finish_reason = str(choices[0].get("finish_reason") or "")
1175
  delta = choices[0].get("delta") or {}
1176
  reasoning_content = (
1177
  delta.get("reasoning_content") or ""
 
1236
  "DeepSeek stream ended without final content."
1237
  )
1238
 
1239
+ completed_at = time.time()
1240
  self._touch_execution_progress("deepseek_stream_complete")
1241
  logging.info(
1242
  "DeepSeek stream complete | seconds=%.1f | reasoning_chunks=%s | content_chunks=%s | content_chars=%s",
1243
+ completed_at - start_time,
1244
  reasoning_chunks,
1245
  content_chunks,
1246
  content_chars,
1247
  )
1248
+ self._emit_deepseek_usage(
1249
+ usage=stream_usage or {},
1250
+ model=current_model,
1251
+ role=resolved_role,
1252
+ attempt_index=attempt,
1253
+ streaming=True,
1254
+ thinking=current_thinking,
1255
+ reasoning_effort=resolved_effort,
1256
+ system_prompt=system_prompt,
1257
+ user_prompt=user_prompt,
1258
+ started_at=start_time,
1259
+ completed_at=completed_at,
1260
+ first_token_at=first_content_at,
1261
+ finish_reason=stream_finish_reason,
1262
+ response_payload=stream_response_payload,
1263
+ )
1264
  return
1265
 
1266
  except GeneratorExit:
core/provider_adapters/gemini_rest_adapter.py ADDED
@@ -0,0 +1,471 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Official Gemini REST adapter used only by isolated A3 provider benchmarks.
3
+
4
+ No Google SDK or local/self-hosted model is required. This adapter deliberately
5
+ contains no paid-provider fallback and does not alter SmartPaper production
6
+ routing by itself.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime, timezone
11
+ import json
12
+ import os
13
+ import time
14
+ from typing import Any, Iterable, Mapping
15
+
16
+ import requests
17
+
18
+ from core.model_usage import (
19
+ ModelUsageEvent,
20
+ emit_usage_event,
21
+ normalize_gemini_usage,
22
+ prompt_identity,
23
+ sanitize_metadata,
24
+ )
25
+
26
+
27
+ class GeminiAPIError(RuntimeError):
28
+ pass
29
+
30
+
31
+ class GeminiAuthenticationError(GeminiAPIError):
32
+ pass
33
+
34
+
35
+ class GeminiRateLimitError(GeminiAPIError):
36
+ pass
37
+
38
+
39
+ class GeminiRequestError(GeminiAPIError):
40
+ pass
41
+
42
+
43
+ class GeminiTimeoutError(GeminiAPIError):
44
+ pass
45
+
46
+
47
+ def _iso_from_epoch(value: float | None) -> str:
48
+ if value is None:
49
+ return ""
50
+ return datetime.fromtimestamp(value, tz=timezone.utc).isoformat()
51
+
52
+
53
+ class GeminiRestAdapter:
54
+ DEFAULT_MODEL = "gemini-2.5-flash"
55
+ DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
56
+
57
+ def __init__(
58
+ self,
59
+ *,
60
+ api_key: str | None = None,
61
+ model_name: str | None = None,
62
+ base_url: str | None = None,
63
+ timeout_seconds: float = 120.0,
64
+ usage_sink: Any = None,
65
+ usage_context: Mapping[str, Any] | None = None,
66
+ thinking_budgets: Mapping[str, int] | None = None,
67
+ ):
68
+ self.api_key = str(api_key or os.environ.get("GEMINI_API_KEY") or "").strip()
69
+ self.model_name = str(model_name or self.DEFAULT_MODEL).strip()
70
+ self.base_url = str(base_url or self.DEFAULT_BASE_URL).rstrip("/")
71
+ self.timeout_seconds = max(1.0, float(timeout_seconds))
72
+ self.usage_sink = usage_sink
73
+ self.usage_context = dict(usage_context or {})
74
+ self.thinking_budgets = {
75
+ "low": 512,
76
+ "medium": 1024,
77
+ "high": 2048,
78
+ **{str(k).lower(): int(v) for k, v in dict(thinking_budgets or {}).items()},
79
+ }
80
+
81
+ def set_usage_context(self, **context: Any) -> None:
82
+ self.usage_context = dict(context or {})
83
+
84
+ def _headers(self) -> dict[str, str]:
85
+ if not self.api_key:
86
+ raise GeminiAuthenticationError(
87
+ "GEMINI_API_KEY is empty for the selected Gemini provider adapter."
88
+ )
89
+ return {
90
+ "Content-Type": "application/json",
91
+ "x-goog-api-key": self.api_key,
92
+ }
93
+
94
+ def _endpoint(self, *, stream: bool, model_name: str | None = None) -> str:
95
+ model = str(model_name or self.model_name).strip() or self.DEFAULT_MODEL
96
+ operation = "streamGenerateContent" if stream else "generateContent"
97
+ suffix = "?alt=sse" if stream else ""
98
+ return f"{self.base_url}/models/{model}:{operation}{suffix}"
99
+
100
+ def _thinking_budget(self, thinking: Any, reasoning_effort: Any) -> int:
101
+ if thinking is False or thinking in (0, "0", "false", "disabled", "off"):
102
+ return 0
103
+ if not thinking:
104
+ return 0
105
+ if isinstance(reasoning_effort, bool):
106
+ return self.thinking_budgets["low"] if reasoning_effort else 0
107
+ try:
108
+ numeric = int(reasoning_effort)
109
+ except (TypeError, ValueError):
110
+ numeric = None
111
+ if numeric is not None:
112
+ return max(0, numeric)
113
+ key = str(reasoning_effort or "low").strip().lower()
114
+ return max(0, int(self.thinking_budgets.get(key, self.thinking_budgets["low"])))
115
+
116
+ def _build_payload(
117
+ self,
118
+ system_prompt: Any,
119
+ user_prompt: Any,
120
+ *,
121
+ max_tokens: int,
122
+ temp: float,
123
+ thinking: Any,
124
+ reasoning_effort: Any,
125
+ response_format: Mapping[str, Any] | None,
126
+ ) -> tuple[dict[str, Any], int]:
127
+ thinking_budget = self._thinking_budget(thinking, reasoning_effort)
128
+ generation_config: dict[str, Any] = {
129
+ "maxOutputTokens": max(1, int(max_tokens)),
130
+ "temperature": max(0.0, min(float(temp), 2.0)),
131
+ "thinkingConfig": {"thinkingBudget": thinking_budget},
132
+ }
133
+ if response_format:
134
+ format_type = str(response_format.get("type") or "").strip().lower()
135
+ if format_type in {"json_object", "json_schema", "application/json"}:
136
+ generation_config["responseMimeType"] = "application/json"
137
+
138
+ payload = {
139
+ "systemInstruction": {
140
+ "parts": [{"text": str(system_prompt or "")}],
141
+ },
142
+ "contents": [
143
+ {
144
+ "role": "user",
145
+ "parts": [{"text": str(user_prompt or "")}],
146
+ }
147
+ ],
148
+ "generationConfig": generation_config,
149
+ }
150
+ return payload, thinking_budget
151
+
152
+ @staticmethod
153
+ def _extract_text(payload: Mapping[str, Any] | None) -> str:
154
+ pieces: list[str] = []
155
+ for candidate in list((payload or {}).get("candidates") or []):
156
+ content = candidate.get("content") if isinstance(candidate, Mapping) else None
157
+ if not isinstance(content, Mapping):
158
+ continue
159
+ for part in list(content.get("parts") or []):
160
+ if not isinstance(part, Mapping):
161
+ continue
162
+ text = part.get("text")
163
+ if text is not None:
164
+ pieces.append(str(text))
165
+ return "".join(pieces)
166
+
167
+ @staticmethod
168
+ def _finish_reason(payload: Mapping[str, Any] | None) -> str:
169
+ candidates = list((payload or {}).get("candidates") or [])
170
+ if not candidates or not isinstance(candidates[0], Mapping):
171
+ return ""
172
+ return str(candidates[0].get("finishReason") or "")[:120]
173
+
174
+ @staticmethod
175
+ def _safe_error_detail(response: Any) -> str:
176
+ try:
177
+ payload = response.json()
178
+ except Exception:
179
+ payload = None
180
+ if isinstance(payload, Mapping):
181
+ error = payload.get("error")
182
+ if isinstance(error, Mapping):
183
+ return str(error.get("message") or error.get("status") or "provider error")[:500]
184
+ return str(getattr(response, "text", "provider error") or "provider error")[:500]
185
+
186
+ def _raise_for_status(self, response: Any) -> None:
187
+ status = int(getattr(response, "status_code", 0) or 0)
188
+ detail = self._safe_error_detail(response)
189
+ if status in (401, 403):
190
+ raise GeminiAuthenticationError(f"Gemini authentication failed ({status}): {detail}")
191
+ if status == 429:
192
+ raise GeminiRateLimitError(f"Gemini rate limit reached (429): {detail}")
193
+ if 400 <= status < 500:
194
+ raise GeminiRequestError(f"Gemini request rejected ({status}): {detail}")
195
+ if status >= 500:
196
+ raise GeminiAPIError(f"Gemini provider error ({status}): {detail}")
197
+ if status != 200:
198
+ raise GeminiAPIError(f"Gemini unexpected HTTP status ({status}): {detail}")
199
+
200
+ def _event(
201
+ self,
202
+ *,
203
+ model: str,
204
+ status: str,
205
+ started_at: float,
206
+ completed_at: float,
207
+ first_token_at: float | None,
208
+ thinking_budget: int,
209
+ system_prompt: Any,
210
+ user_prompt: Any,
211
+ usage: Mapping[str, Any] | None = None,
212
+ response_payload: Mapping[str, Any] | None = None,
213
+ streaming: bool = False,
214
+ http_status: int | None = None,
215
+ error: BaseException | None = None,
216
+ ) -> ModelUsageEvent:
217
+ normalized = normalize_gemini_usage(usage)
218
+ context = dict(self.usage_context or {})
219
+ payload = dict(response_payload or {})
220
+ return ModelUsageEvent(
221
+ provider="gemini",
222
+ model=model,
223
+ status=status,
224
+ phase_id=str(context.get("phase_id") or context.get("capability") or ""),
225
+ case_id=str(context.get("case_id") or ""),
226
+ benchmark_version=str(context.get("benchmark_version") or ""),
227
+ profile_id=str(context.get("profile_id") or ""),
228
+ attempt_index=int(context.get("attempt_index") or 0),
229
+ streaming=bool(streaming),
230
+ thinking_mode="disabled" if thinking_budget == 0 else "enabled",
231
+ requested_thinking_budget=thinking_budget,
232
+ prompt_hash=prompt_identity(system_prompt, user_prompt),
233
+ request_started_at=_iso_from_epoch(started_at),
234
+ first_token_at=_iso_from_epoch(first_token_at),
235
+ completed_at=_iso_from_epoch(completed_at),
236
+ latency_ms=max(0, int((completed_at - started_at) * 1000)),
237
+ prompt_input_tokens=int(normalized.get("prompt_input_tokens") or 0),
238
+ cache_hit_tokens=int(normalized.get("cache_hit_tokens") or 0),
239
+ cache_miss_tokens=int(normalized.get("cache_miss_tokens") or 0),
240
+ output_tokens=int(normalized.get("output_tokens") or 0),
241
+ reasoning_or_thought_tokens=int(normalized.get("reasoning_or_thought_tokens") or 0),
242
+ total_tokens=int(normalized.get("total_tokens") or 0),
243
+ finish_reason=self._finish_reason(payload),
244
+ http_status=http_status,
245
+ error_class=error.__class__.__name__ if error else "",
246
+ provider_service_tier=str(normalized.get("provider_service_tier") or ""),
247
+ provider_model_version=str(payload.get("modelVersion") or "")[:160],
248
+ provider_response_id=str(payload.get("responseId") or "")[:160],
249
+ metadata=sanitize_metadata({
250
+ "provider_model_version": payload.get("modelVersion"),
251
+ "provider_response_id": payload.get("responseId"),
252
+ }),
253
+ )
254
+
255
+ def call(
256
+ self,
257
+ system_prompt: Any,
258
+ user_prompt: Any,
259
+ max_tokens: int = 8000,
260
+ temp: float = 0.3,
261
+ model_name: str | None = None,
262
+ model_role: str = "reasoning",
263
+ thinking: Any = None,
264
+ reasoning_effort: Any = None,
265
+ allow_fallback: Any = None,
266
+ response_format: Mapping[str, Any] | None = None,
267
+ **_: Any,
268
+ ) -> str:
269
+ del model_role, allow_fallback
270
+ model = str(model_name or self.model_name).strip() or self.DEFAULT_MODEL
271
+ payload, thinking_budget = self._build_payload(
272
+ system_prompt,
273
+ user_prompt,
274
+ max_tokens=max_tokens,
275
+ temp=temp,
276
+ thinking=thinking,
277
+ reasoning_effort=reasoning_effort,
278
+ response_format=response_format,
279
+ )
280
+ started_at = time.time()
281
+ response = None
282
+ try:
283
+ response = requests.post(
284
+ self._endpoint(stream=False, model_name=model),
285
+ headers=self._headers(),
286
+ json=payload,
287
+ timeout=self.timeout_seconds,
288
+ )
289
+ self._raise_for_status(response)
290
+ try:
291
+ result = response.json()
292
+ except ValueError as exc:
293
+ raise GeminiAPIError("Gemini returned a non-JSON success response.") from exc
294
+ content = self._extract_text(result).strip()
295
+ if not content:
296
+ raise GeminiAPIError(
297
+ "Gemini returned empty final content. "
298
+ f"finish_reason={self._finish_reason(result)!r}"
299
+ )
300
+ completed_at = time.time()
301
+ emit_usage_event(
302
+ self.usage_sink,
303
+ self._event(
304
+ model=model,
305
+ status="success",
306
+ started_at=started_at,
307
+ completed_at=completed_at,
308
+ first_token_at=None,
309
+ thinking_budget=thinking_budget,
310
+ system_prompt=system_prompt,
311
+ user_prompt=user_prompt,
312
+ usage=result.get("usageMetadata") if isinstance(result, Mapping) else None,
313
+ response_payload=result,
314
+ streaming=False,
315
+ http_status=int(getattr(response, "status_code", 200) or 200),
316
+ ),
317
+ )
318
+ return content
319
+ except requests.exceptions.Timeout as exc:
320
+ error = GeminiTimeoutError("Gemini request timed out.")
321
+ self._emit_error(model, started_at, thinking_budget, system_prompt, user_prompt, response, error, False)
322
+ raise error from exc
323
+ except requests.exceptions.ConnectionError as exc:
324
+ error = GeminiAPIError("Gemini connection failed.")
325
+ self._emit_error(model, started_at, thinking_budget, system_prompt, user_prompt, response, error, False)
326
+ raise error from exc
327
+ except GeminiAPIError as error:
328
+ self._emit_error(model, started_at, thinking_budget, system_prompt, user_prompt, response, error, False)
329
+ raise
330
+
331
+ def _emit_error(
332
+ self,
333
+ model: str,
334
+ started_at: float,
335
+ thinking_budget: int,
336
+ system_prompt: Any,
337
+ user_prompt: Any,
338
+ response: Any,
339
+ error: BaseException,
340
+ streaming: bool,
341
+ ) -> None:
342
+ completed_at = time.time()
343
+ emit_usage_event(
344
+ self.usage_sink,
345
+ self._event(
346
+ model=model,
347
+ status="error",
348
+ started_at=started_at,
349
+ completed_at=completed_at,
350
+ first_token_at=None,
351
+ thinking_budget=thinking_budget,
352
+ system_prompt=system_prompt,
353
+ user_prompt=user_prompt,
354
+ usage=None,
355
+ response_payload=None,
356
+ streaming=streaming,
357
+ http_status=int(getattr(response, "status_code", 0) or 0) or None,
358
+ error=error,
359
+ ),
360
+ )
361
+
362
+ @staticmethod
363
+ def _parse_sse_line(raw_line: Any) -> Mapping[str, Any] | None:
364
+ if raw_line is None:
365
+ return None
366
+ if isinstance(raw_line, bytes):
367
+ raw_line = raw_line.decode("utf-8", errors="replace")
368
+ line = str(raw_line).strip()
369
+ if not line or line.startswith(":") or not line.startswith("data:"):
370
+ return None
371
+ data = line[5:].strip()
372
+ if not data or data == "[DONE]":
373
+ return None
374
+ try:
375
+ parsed = json.loads(data)
376
+ except json.JSONDecodeError as exc:
377
+ raise GeminiAPIError("Gemini streaming returned malformed SSE JSON.") from exc
378
+ if not isinstance(parsed, Mapping):
379
+ raise GeminiAPIError("Gemini streaming returned a non-object SSE payload.")
380
+ return parsed
381
+
382
+ def stream(
383
+ self,
384
+ system_prompt: Any,
385
+ user_prompt: Any,
386
+ max_tokens: int = 8000,
387
+ model_name: str | None = None,
388
+ model_role: str = "reasoning",
389
+ thinking: Any = None,
390
+ reasoning_effort: Any = None,
391
+ temp: float = 0.3,
392
+ allow_fallback: Any = None,
393
+ reasoning_only_recovery: Any = None,
394
+ **_: Any,
395
+ ) -> Iterable[str]:
396
+ del model_role, allow_fallback, reasoning_only_recovery
397
+ model = str(model_name or self.model_name).strip() or self.DEFAULT_MODEL
398
+ payload, thinking_budget = self._build_payload(
399
+ system_prompt,
400
+ user_prompt,
401
+ max_tokens=max_tokens,
402
+ temp=temp,
403
+ thinking=thinking,
404
+ reasoning_effort=reasoning_effort,
405
+ response_format=None,
406
+ )
407
+ started_at = time.time()
408
+ first_token_at = None
409
+ response = None
410
+ latest_usage: Mapping[str, Any] | None = None
411
+ latest_payload: Mapping[str, Any] | None = None
412
+ emitted = False
413
+ try:
414
+ with requests.post(
415
+ self._endpoint(stream=True, model_name=model),
416
+ headers=self._headers(),
417
+ json=payload,
418
+ stream=True,
419
+ timeout=self.timeout_seconds,
420
+ ) as response:
421
+ self._raise_for_status(response)
422
+ for raw_line in response.iter_lines(decode_unicode=True):
423
+ event = self._parse_sse_line(raw_line)
424
+ if not event:
425
+ continue
426
+ latest_payload = event
427
+ if isinstance(event.get("usageMetadata"), Mapping):
428
+ latest_usage = event.get("usageMetadata")
429
+ text = self._extract_text(event)
430
+ if text:
431
+ emitted = True
432
+ if first_token_at is None:
433
+ first_token_at = time.time()
434
+ yield text
435
+ if not emitted:
436
+ raise GeminiAPIError("Gemini stream ended without final content.")
437
+ completed_at = time.time()
438
+ emit_usage_event(
439
+ self.usage_sink,
440
+ self._event(
441
+ model=model,
442
+ status="success",
443
+ started_at=started_at,
444
+ completed_at=completed_at,
445
+ first_token_at=first_token_at,
446
+ thinking_budget=thinking_budget,
447
+ system_prompt=system_prompt,
448
+ user_prompt=user_prompt,
449
+ usage=latest_usage,
450
+ response_payload=latest_payload,
451
+ streaming=True,
452
+ http_status=int(getattr(response, "status_code", 200) or 200),
453
+ ),
454
+ )
455
+ except GeneratorExit:
456
+ raise
457
+ except requests.exceptions.Timeout as exc:
458
+ error = GeminiTimeoutError("Gemini streaming request timed out.")
459
+ self._emit_error(model, started_at, thinking_budget, system_prompt, user_prompt, response, error, True)
460
+ raise error from exc
461
+ except requests.exceptions.ConnectionError as exc:
462
+ error = GeminiAPIError("Gemini streaming connection failed.")
463
+ self._emit_error(model, started_at, thinking_budget, system_prompt, user_prompt, response, error, True)
464
+ raise error from exc
465
+ except requests.exceptions.ChunkedEncodingError as exc:
466
+ error = GeminiAPIError("Gemini streaming response ended prematurely.")
467
+ self._emit_error(model, started_at, thinking_budget, system_prompt, user_prompt, response, error, True)
468
+ raise error from exc
469
+ except GeminiAPIError as error:
470
+ self._emit_error(model, started_at, thinking_budget, system_prompt, user_prompt, response, error, True)
471
+ raise
core/provider_adapters/glm_rest_adapter.py ADDED
@@ -0,0 +1,495 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Official Zhipu GLM REST adapter for isolated A3 provider benchmarks.
3
+
4
+ The adapter is remote-API only. It does not select products, regions, or
5
+ production routes, and it contains no internal fallback to another provider.
6
+ Provider/model/base-URL selection is supplied by benchmark policy/config so a
7
+ future server-authoritative routing layer can make reachability decisions
8
+ without hard-coding geography into manuscript code.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from datetime import datetime, timezone
13
+ import json
14
+ import time
15
+ from typing import Any, Iterable, Mapping
16
+
17
+ import requests
18
+
19
+ from core.model_usage import (
20
+ ModelUsageEvent,
21
+ emit_usage_event,
22
+ normalize_zhipu_usage,
23
+ prompt_identity,
24
+ sanitize_metadata,
25
+ )
26
+
27
+
28
+ class ZhipuAPIError(RuntimeError):
29
+ pass
30
+
31
+
32
+ class ZhipuAuthenticationError(ZhipuAPIError):
33
+ pass
34
+
35
+
36
+ class ZhipuRateLimitError(ZhipuAPIError):
37
+ pass
38
+
39
+
40
+ class ZhipuRequestError(ZhipuAPIError):
41
+ pass
42
+
43
+
44
+ class ZhipuTimeoutError(ZhipuAPIError):
45
+ pass
46
+
47
+
48
+ def _iso_from_epoch(value: float | None) -> str:
49
+ if value is None:
50
+ return ""
51
+ return datetime.fromtimestamp(value, tz=timezone.utc).isoformat()
52
+
53
+
54
+ def _thinking_enabled(value: Any) -> bool:
55
+ if value is None:
56
+ return False
57
+ if isinstance(value, str):
58
+ return value.strip().lower() not in {"", "0", "false", "disabled", "off", "none"}
59
+ return bool(value)
60
+
61
+
62
+ class ZhipuGLMRestAdapter:
63
+ """OpenAI-style GLM Chat Completions adapter.
64
+
65
+ GLM-4.7-Flash supports thinking enabled/disabled but does not support the
66
+ GLM-5.2-only ``reasoning_effort`` control. The common argument is accepted
67
+ for gateway signature compatibility and intentionally not transmitted.
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ *,
73
+ api_key: str | None = None,
74
+ model_name: str | None = None,
75
+ base_url: str | None = None,
76
+ timeout_seconds: float = 120.0,
77
+ usage_sink: Any = None,
78
+ usage_context: Mapping[str, Any] | None = None,
79
+ ):
80
+ self.api_key = str(api_key or "").strip()
81
+ self.model_name = str(model_name or "").strip()
82
+ self.base_url = str(base_url or "").rstrip("/")
83
+ self.timeout_seconds = max(1.0, float(timeout_seconds))
84
+ self.usage_sink = usage_sink
85
+ self.usage_context = dict(usage_context or {})
86
+
87
+ def set_usage_context(self, **context: Any) -> None:
88
+ self.usage_context = dict(context or {})
89
+
90
+ def _headers(self) -> dict[str, str]:
91
+ if not self.api_key:
92
+ raise ZhipuAuthenticationError(
93
+ "API key is empty for the selected Zhipu provider adapter."
94
+ )
95
+ return {
96
+ "Content-Type": "application/json",
97
+ "Authorization": f"Bearer {self.api_key}",
98
+ }
99
+
100
+ def _endpoint(self) -> str:
101
+ if not self.base_url:
102
+ raise ZhipuRequestError("Zhipu base URL is not configured.")
103
+ return f"{self.base_url}/chat/completions"
104
+
105
+ def _model(self, model_name: str | None) -> str:
106
+ model = str(model_name or self.model_name or "").strip()
107
+ if not model:
108
+ raise ZhipuRequestError("Zhipu model is not configured.")
109
+ return model
110
+
111
+ def _build_payload(
112
+ self,
113
+ system_prompt: Any,
114
+ user_prompt: Any,
115
+ *,
116
+ model: str,
117
+ max_tokens: int,
118
+ temp: float,
119
+ thinking: Any,
120
+ response_format: Mapping[str, Any] | None,
121
+ ) -> tuple[dict[str, Any], bool]:
122
+ enabled = _thinking_enabled(thinking)
123
+ payload: dict[str, Any] = {
124
+ "model": model,
125
+ "messages": [
126
+ {"role": "system", "content": str(system_prompt or "")},
127
+ {"role": "user", "content": str(user_prompt or "")},
128
+ ],
129
+ "max_tokens": max(1, int(max_tokens)),
130
+ "temperature": max(0.0, min(float(temp), 1.0)),
131
+ "thinking": {"type": "enabled" if enabled else "disabled"},
132
+ }
133
+ if response_format:
134
+ format_type = str(response_format.get("type") or "").strip().lower()
135
+ if format_type in {"json_object", "application/json"}:
136
+ payload["response_format"] = {"type": "json_object"}
137
+ return payload, enabled
138
+
139
+ @staticmethod
140
+ def _extract_static_text(payload: Mapping[str, Any] | None) -> str:
141
+ choices = list((payload or {}).get("choices") or [])
142
+ if not choices or not isinstance(choices[0], Mapping):
143
+ return ""
144
+ message = choices[0].get("message")
145
+ if not isinstance(message, Mapping):
146
+ return ""
147
+ # Deliberately exclude reasoning_content from reader-facing output.
148
+ return str(message.get("content") or "")
149
+
150
+ @staticmethod
151
+ def _extract_stream_text(payload: Mapping[str, Any] | None) -> str:
152
+ choices = list((payload or {}).get("choices") or [])
153
+ if not choices or not isinstance(choices[0], Mapping):
154
+ return ""
155
+ delta = choices[0].get("delta")
156
+ if not isinstance(delta, Mapping):
157
+ return ""
158
+ # Deliberately exclude reasoning_content from reader-facing output.
159
+ return str(delta.get("content") or "")
160
+
161
+ @staticmethod
162
+ def _finish_reason(payload: Mapping[str, Any] | None) -> str:
163
+ choices = list((payload or {}).get("choices") or [])
164
+ if not choices or not isinstance(choices[0], Mapping):
165
+ return ""
166
+ return str(choices[0].get("finish_reason") or "")[:120]
167
+
168
+ @staticmethod
169
+ def _safe_error_detail(response: Any) -> str:
170
+ try:
171
+ payload = response.json()
172
+ except Exception:
173
+ payload = None
174
+ if isinstance(payload, Mapping):
175
+ error = payload.get("error")
176
+ if isinstance(error, Mapping):
177
+ return str(error.get("message") or error.get("code") or "provider error")[:500]
178
+ return str(payload.get("message") or payload.get("msg") or "provider error")[:500]
179
+ return str(getattr(response, "text", "provider error") or "provider error")[:500]
180
+
181
+ def _raise_for_status(self, response: Any) -> None:
182
+ status = int(getattr(response, "status_code", 0) or 0)
183
+ detail = self._safe_error_detail(response)
184
+ if status in (401, 403):
185
+ raise ZhipuAuthenticationError(f"Zhipu authentication failed ({status}): {detail}")
186
+ if status == 429:
187
+ raise ZhipuRateLimitError(f"Zhipu rate limit reached (429): {detail}")
188
+ if 400 <= status < 500:
189
+ raise ZhipuRequestError(f"Zhipu request rejected ({status}): {detail}")
190
+ if status >= 500:
191
+ raise ZhipuAPIError(f"Zhipu provider error ({status}): {detail}")
192
+ if status != 200:
193
+ raise ZhipuAPIError(f"Zhipu unexpected HTTP status ({status}): {detail}")
194
+
195
+ def _event(
196
+ self,
197
+ *,
198
+ model: str,
199
+ status: str,
200
+ started_at: float,
201
+ completed_at: float,
202
+ first_token_at: float | None,
203
+ thinking_enabled: bool,
204
+ system_prompt: Any,
205
+ user_prompt: Any,
206
+ usage: Mapping[str, Any] | None = None,
207
+ response_payload: Mapping[str, Any] | None = None,
208
+ streaming: bool = False,
209
+ http_status: int | None = None,
210
+ error: BaseException | None = None,
211
+ ) -> ModelUsageEvent:
212
+ normalized = normalize_zhipu_usage(usage)
213
+ context = dict(self.usage_context or {})
214
+ payload = dict(response_payload or {})
215
+ return ModelUsageEvent(
216
+ provider="zhipu",
217
+ model=model,
218
+ status=status,
219
+ phase_id=str(context.get("phase_id") or context.get("capability") or ""),
220
+ case_id=str(context.get("case_id") or ""),
221
+ benchmark_version=str(context.get("benchmark_version") or ""),
222
+ profile_id=str(context.get("profile_id") or ""),
223
+ attempt_index=int(context.get("attempt_index") or 0),
224
+ streaming=bool(streaming),
225
+ thinking_mode="enabled" if thinking_enabled else "disabled",
226
+ requested_thinking_budget=None,
227
+ prompt_hash=prompt_identity(system_prompt, user_prompt),
228
+ request_started_at=_iso_from_epoch(started_at),
229
+ first_token_at=_iso_from_epoch(first_token_at),
230
+ completed_at=_iso_from_epoch(completed_at),
231
+ latency_ms=max(0, int((completed_at - started_at) * 1000)),
232
+ prompt_input_tokens=int(normalized.get("prompt_input_tokens") or 0),
233
+ cache_hit_tokens=int(normalized.get("cache_hit_tokens") or 0),
234
+ cache_miss_tokens=int(normalized.get("cache_miss_tokens") or 0),
235
+ output_tokens=int(normalized.get("output_tokens") or 0),
236
+ reasoning_or_thought_tokens=int(normalized.get("reasoning_or_thought_tokens") or 0),
237
+ total_tokens=int(normalized.get("total_tokens") or 0),
238
+ finish_reason=self._finish_reason(payload),
239
+ http_status=http_status,
240
+ error_class=error.__class__.__name__ if error else "",
241
+ provider_model_version=str(payload.get("model") or model)[:160],
242
+ provider_response_id=str(payload.get("id") or payload.get("request_id") or "")[:160],
243
+ metadata=sanitize_metadata({
244
+ "provider_model": payload.get("model"),
245
+ "provider_response_id": payload.get("id") or payload.get("request_id"),
246
+ }),
247
+ )
248
+
249
+ def _emit_error(
250
+ self,
251
+ *,
252
+ model: str,
253
+ started_at: float,
254
+ thinking_enabled: bool,
255
+ system_prompt: Any,
256
+ user_prompt: Any,
257
+ response: Any,
258
+ error: BaseException,
259
+ streaming: bool,
260
+ ) -> None:
261
+ completed_at = time.time()
262
+ emit_usage_event(
263
+ self.usage_sink,
264
+ self._event(
265
+ model=model,
266
+ status="error",
267
+ started_at=started_at,
268
+ completed_at=completed_at,
269
+ first_token_at=None,
270
+ thinking_enabled=thinking_enabled,
271
+ system_prompt=system_prompt,
272
+ user_prompt=user_prompt,
273
+ usage=None,
274
+ response_payload=None,
275
+ streaming=streaming,
276
+ http_status=int(getattr(response, "status_code", 0) or 0) or None,
277
+ error=error,
278
+ ),
279
+ )
280
+
281
+ def call(
282
+ self,
283
+ system_prompt: Any,
284
+ user_prompt: Any,
285
+ max_tokens: int = 8000,
286
+ temp: float = 0.3,
287
+ model_name: str | None = None,
288
+ model_role: str = "reasoning",
289
+ thinking: Any = None,
290
+ reasoning_effort: Any = None,
291
+ allow_fallback: Any = None,
292
+ response_format: Mapping[str, Any] | None = None,
293
+ **_: Any,
294
+ ) -> str:
295
+ del model_role, reasoning_effort, allow_fallback
296
+ model = self._model(model_name)
297
+ payload, thinking_enabled = self._build_payload(
298
+ system_prompt,
299
+ user_prompt,
300
+ model=model,
301
+ max_tokens=max_tokens,
302
+ temp=temp,
303
+ thinking=thinking,
304
+ response_format=response_format,
305
+ )
306
+ started_at = time.time()
307
+ response = None
308
+ try:
309
+ response = requests.post(
310
+ self._endpoint(),
311
+ headers=self._headers(),
312
+ json=payload,
313
+ timeout=self.timeout_seconds,
314
+ )
315
+ self._raise_for_status(response)
316
+ try:
317
+ result = response.json()
318
+ except ValueError as exc:
319
+ raise ZhipuAPIError("Zhipu returned a non-JSON success response.") from exc
320
+ content = self._extract_static_text(result).strip()
321
+ if not content:
322
+ raise ZhipuAPIError(
323
+ "Zhipu returned empty final content. "
324
+ f"finish_reason={self._finish_reason(result)!r}"
325
+ )
326
+ completed_at = time.time()
327
+ emit_usage_event(
328
+ self.usage_sink,
329
+ self._event(
330
+ model=model,
331
+ status="success",
332
+ started_at=started_at,
333
+ completed_at=completed_at,
334
+ first_token_at=None,
335
+ thinking_enabled=thinking_enabled,
336
+ system_prompt=system_prompt,
337
+ user_prompt=user_prompt,
338
+ usage=result.get("usage") if isinstance(result, Mapping) else None,
339
+ response_payload=result,
340
+ streaming=False,
341
+ http_status=int(getattr(response, "status_code", 200) or 200),
342
+ ),
343
+ )
344
+ return content
345
+ except requests.exceptions.Timeout as exc:
346
+ error = ZhipuTimeoutError("Zhipu request timed out.")
347
+ self._emit_error(
348
+ model=model, started_at=started_at, thinking_enabled=thinking_enabled,
349
+ system_prompt=system_prompt, user_prompt=user_prompt, response=response,
350
+ error=error, streaming=False,
351
+ )
352
+ raise error from exc
353
+ except requests.exceptions.ConnectionError as exc:
354
+ error = ZhipuAPIError("Zhipu connection failed.")
355
+ self._emit_error(
356
+ model=model, started_at=started_at, thinking_enabled=thinking_enabled,
357
+ system_prompt=system_prompt, user_prompt=user_prompt, response=response,
358
+ error=error, streaming=False,
359
+ )
360
+ raise error from exc
361
+ except ZhipuAPIError as error:
362
+ self._emit_error(
363
+ model=model, started_at=started_at, thinking_enabled=thinking_enabled,
364
+ system_prompt=system_prompt, user_prompt=user_prompt, response=response,
365
+ error=error, streaming=False,
366
+ )
367
+ raise
368
+
369
+ @staticmethod
370
+ def _parse_sse_line(raw_line: Any) -> Mapping[str, Any] | None:
371
+ if raw_line is None:
372
+ return None
373
+ if isinstance(raw_line, bytes):
374
+ raw_line = raw_line.decode("utf-8", errors="replace")
375
+ line = str(raw_line).strip()
376
+ if not line or line.startswith(":") or not line.startswith("data:"):
377
+ return None
378
+ data = line[5:].strip()
379
+ if not data or data == "[DONE]":
380
+ return None
381
+ try:
382
+ parsed = json.loads(data)
383
+ except json.JSONDecodeError as exc:
384
+ raise ZhipuAPIError("Zhipu streaming returned malformed SSE JSON.") from exc
385
+ if not isinstance(parsed, Mapping):
386
+ raise ZhipuAPIError("Zhipu streaming returned a non-object SSE payload.")
387
+ return parsed
388
+
389
+ def stream(
390
+ self,
391
+ system_prompt: Any,
392
+ user_prompt: Any,
393
+ max_tokens: int = 8000,
394
+ model_name: str | None = None,
395
+ model_role: str = "reasoning",
396
+ thinking: Any = None,
397
+ reasoning_effort: Any = None,
398
+ temp: float = 0.3,
399
+ allow_fallback: Any = None,
400
+ reasoning_only_recovery: Any = None,
401
+ **_: Any,
402
+ ) -> Iterable[str]:
403
+ del model_role, reasoning_effort, allow_fallback, reasoning_only_recovery
404
+ model = self._model(model_name)
405
+ payload, thinking_enabled = self._build_payload(
406
+ system_prompt,
407
+ user_prompt,
408
+ model=model,
409
+ max_tokens=max_tokens,
410
+ temp=temp,
411
+ thinking=thinking,
412
+ response_format=None,
413
+ )
414
+ payload["stream"] = True
415
+ started_at = time.time()
416
+ first_token_at = None
417
+ response = None
418
+ latest_usage: Mapping[str, Any] | None = None
419
+ latest_payload: Mapping[str, Any] | None = None
420
+ emitted = False
421
+ try:
422
+ with requests.post(
423
+ self._endpoint(),
424
+ headers=self._headers(),
425
+ json=payload,
426
+ stream=True,
427
+ timeout=self.timeout_seconds,
428
+ ) as response:
429
+ self._raise_for_status(response)
430
+ for raw_line in response.iter_lines(decode_unicode=True):
431
+ event = self._parse_sse_line(raw_line)
432
+ if not event:
433
+ continue
434
+ latest_payload = event
435
+ if isinstance(event.get("usage"), Mapping):
436
+ latest_usage = event.get("usage")
437
+ text = self._extract_stream_text(event)
438
+ if text:
439
+ emitted = True
440
+ if first_token_at is None:
441
+ first_token_at = time.time()
442
+ yield text
443
+ if not emitted:
444
+ raise ZhipuAPIError("Zhipu stream ended without final content.")
445
+ completed_at = time.time()
446
+ emit_usage_event(
447
+ self.usage_sink,
448
+ self._event(
449
+ model=model,
450
+ status="success",
451
+ started_at=started_at,
452
+ completed_at=completed_at,
453
+ first_token_at=first_token_at,
454
+ thinking_enabled=thinking_enabled,
455
+ system_prompt=system_prompt,
456
+ user_prompt=user_prompt,
457
+ usage=latest_usage,
458
+ response_payload=latest_payload,
459
+ streaming=True,
460
+ http_status=int(getattr(response, "status_code", 200) or 200),
461
+ ),
462
+ )
463
+ except GeneratorExit:
464
+ raise
465
+ except requests.exceptions.Timeout as exc:
466
+ error = ZhipuTimeoutError("Zhipu streaming request timed out.")
467
+ self._emit_error(
468
+ model=model, started_at=started_at, thinking_enabled=thinking_enabled,
469
+ system_prompt=system_prompt, user_prompt=user_prompt, response=response,
470
+ error=error, streaming=True,
471
+ )
472
+ raise error from exc
473
+ except requests.exceptions.ConnectionError as exc:
474
+ error = ZhipuAPIError("Zhipu streaming connection failed.")
475
+ self._emit_error(
476
+ model=model, started_at=started_at, thinking_enabled=thinking_enabled,
477
+ system_prompt=system_prompt, user_prompt=user_prompt, response=response,
478
+ error=error, streaming=True,
479
+ )
480
+ raise error from exc
481
+ except requests.exceptions.ChunkedEncodingError as exc:
482
+ error = ZhipuAPIError("Zhipu streaming response ended prematurely.")
483
+ self._emit_error(
484
+ model=model, started_at=started_at, thinking_enabled=thinking_enabled,
485
+ system_prompt=system_prompt, user_prompt=user_prompt, response=response,
486
+ error=error, streaming=True,
487
+ )
488
+ raise error from exc
489
+ except ZhipuAPIError as error:
490
+ self._emit_error(
491
+ model=model, started_at=started_at, thinking_enabled=thinking_enabled,
492
+ system_prompt=system_prompt, user_prompt=user_prompt, response=response,
493
+ error=error, streaming=True,
494
+ )
495
+ raise